From 7cd44ccb64dfc29fff88c5d62efe586facbfe610 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Wed, 13 May 2026 09:42:42 +0530 Subject: [PATCH] promote: email-link approval workflow for non-dev promotions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an email-link approval gate to POST /api/v1/stacks/:slug/promote and POST /api/v1/resources/:id/provision-twin. Any target env other than "development" now persists a pending row in promote_approvals, emits a promote.approval_requested audit row (the Brevo forwarder picks this up and sends the email), and returns 202 with an approval_id + expires_at + agent_action telling the user to check their inbox. Dev-env promotes execute immediately and are entirely unchanged. Migration 026 backs a single table with status (pending|approved|rejected| expired|executed), a 32-byte crypto/rand token, 24h expiry, and JSONB payload so a worker (separate PR) can replay the original POST. Single- use approval is enforced via atomic UPDATE ... WHERE status='pending' AND expires_at > now() — concurrent clicks resolve to exactly one approval. Public route GET /approve/:token requires no auth (the token IS the credential) and is rate-limited to 10 req/sec per IP via a Redis INCR with 2s TTL — defends the 32-byte token space against brute force while failing open on Redis errors. Admin routes GET /promotions and POST /promotions/:id/reject live behind the existing ADMIN_PATH_PREFIX + ADMIN_EMAILS gates. Existing stack_promote / provision_twin tests retargeted at to="development" so they exercise the pre-approval contract that's still under test; non-dev coverage lives in the new promote_approval_test.go suite (12 tests, all 9 prompt cases plus token-uniqueness, atomic-single-use, and the admin-list shape). Worker-side polling that auto-executes approved rows is out of scope for this PR — flag as follow-up. Manual trigger today: re-call the original endpoint with approval_id in the body; consumeApprovedPromote / consumeApprovedTwin verify (kind,from,to) match before flipping to executed. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../db/migrations/026_promote_approvals.sql | 60 ++ internal/handlers/agent_action.go | 31 + .../handlers/agent_action_contract_test.go | 3 + internal/handlers/openapi.go | 47 +- internal/handlers/promote_approval.go | 594 +++++++++++++++++ internal/handlers/promote_approval_test.go | 631 ++++++++++++++++++ internal/handlers/stack.go | 205 +++++- internal/handlers/stack_promote_test.go | 32 +- internal/handlers/stack_promote_vault_test.go | 6 +- internal/handlers/twin.go | 162 +++++ internal/handlers/twin_test.go | 25 +- internal/models/audit_kinds.go | 29 + internal/models/promote_approvals.go | 350 ++++++++++ internal/router/router.go | 15 + internal/testhelpers/testhelpers.go | 21 + 15 files changed, 2169 insertions(+), 42 deletions(-) create mode 100644 internal/db/migrations/026_promote_approvals.sql create mode 100644 internal/handlers/promote_approval.go create mode 100644 internal/handlers/promote_approval_test.go create mode 100644 internal/models/promote_approvals.go diff --git a/internal/db/migrations/026_promote_approvals.sql b/internal/db/migrations/026_promote_approvals.sql new file mode 100644 index 00000000..1563d726 --- /dev/null +++ b/internal/db/migrations/026_promote_approvals.sql @@ -0,0 +1,60 @@ +-- Migration: 026_promote_approvals — email-link approval workflow for env +-- promotions targeting non-development environments. +-- +-- Why this table exists: today POST /api/v1/stacks/:slug/promote and POST +-- /api/v1/resources/:id/provision-twin execute immediately when an admin or +-- operator calls them. Product directive: promotions to staging / preprod / +-- production / etc. must require an explicit human approval via email link +-- before they execute. Dev-env promotes are unchanged — they bypass this +-- table entirely so the inner-loop developer experience stays one-call. +-- +-- Lifecycle of a row: +-- +-- 1. API creates a row with status='pending', a 32-byte URL-safe random +-- token, and expires_at = now() + 24h. +-- 2. The Brevo forwarder (worker side) picks up the audit_log row of kind +-- 'promote.approval_requested' and emails the operator a clickable +-- https://api.instanode.dev/approve/ link. +-- 3. Operator clicks → GET /approve/ atomically flips status to +-- 'approved' (single-use: ON UPDATE WHERE status='pending') and +-- records approved_at. Already-clicked links report "already used"; +-- expired links report "link expired" and flip status to 'expired'. +-- 4. A worker (separate PR) polls for status='approved' AND +-- executed_at IS NULL, runs the original promote with the cached +-- promote_payload, and stamps executed_at. Out of scope for this PR. +-- 5. Admins can mark a row 'rejected' via POST /api/v1/promotions/:id/reject. +-- +-- The promote_payload column carries the original POST body so the worker +-- can replay the request without re-fetching state that may have changed. +-- promote_kind is 'stack' or 'resource_twin' so the worker knows which +-- code path to call. + +CREATE TABLE IF NOT EXISTS promote_approvals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token TEXT UNIQUE NOT NULL, + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + requested_by_email TEXT NOT NULL, + promote_kind TEXT NOT NULL, -- 'stack' | 'resource_twin' + promote_payload JSONB NOT NULL, -- the original POST body + from_env TEXT NOT NULL, + to_env TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', -- pending | approved | rejected | expired | executed + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + approved_at TIMESTAMPTZ, + executed_at TIMESTAMPTZ, + rejected_at TIMESTAMPTZ +); + +-- Backs the GET /approve/:token lookup. Partial index on status='pending' so +-- the hot lookup path scans only live rows; expired / approved / rejected +-- tokens degrade to a full-scan miss (which returns ErrNotFound). +CREATE INDEX IF NOT EXISTS idx_promote_approvals_token + ON promote_approvals(token) WHERE status = 'pending'; + +-- Backs the worker's pending-execution poll: "find rows that are approved +-- but not yet executed." Partial index keeps it tiny — most rows are either +-- pending (waiting for click) or executed (already run, dead weight in this +-- index but never matched). +CREATE INDEX IF NOT EXISTS idx_promote_approvals_pending_exec + ON promote_approvals(status) WHERE status = 'approved' AND executed_at IS NULL; diff --git a/internal/handlers/agent_action.go b/internal/handlers/agent_action.go index 33c91558..94c07665 100644 --- a/internal/handlers/agent_action.go +++ b/internal/handlers/agent_action.go @@ -283,3 +283,34 @@ func newAgentActionAdminPromoIssued(teamID, code string) string { code, teamID, ) } + +// ───────────────────────────────────────────────────────────────────────────── +// Promote-approval walls (POST /api/v1/stacks/:slug/promote + +// POST /api/v1/resources/:id/provision-twin, non-dev target envs) +// ───────────────────────────────────────────────────────────────────────────── + +// newAgentActionPromoteApprovalSent is returned in the 202 response body when +// the API has accepted a promote / twin-provision request that targets a +// non-development env. Names the env, the email recipient, and the next +// action — the LLM agent re-articulates "go check your inbox" to the human +// in front of it. Dev-env promotes bypass this code path entirely (immediate +// execute), so this string never fires for development targets. +// +// The 24h validity window is reproduced verbatim so the agent doesn't have +// to fish it out of the JSON body's expires_at field. +func newAgentActionPromoteApprovalSent(toEnv, recipientEmail string) string { + if recipientEmail == "" { + recipientEmail = "the team owner's email" + } + return fmt.Sprintf( + "Tell the user the promote to %s requires email approval. Check %s for a link expiring in 24h. Dev-env promotes skip this step. Track at https://instanode.dev/app/promotions.", + toEnv, recipientEmail, + ) +} + +// AgentActionPromoteTokenExpired is returned by the GET /approve/:token +// HTML response copy (rendered in-page) and as the agent_action on any +// retry attempt that hits an expired link. The user must re-request the +// promote from the dashboard — re-using the same email link will never +// work because the row's status is now 'expired'. +const AgentActionPromoteTokenExpired = "Tell the user the approval link expired. Re-request the promote at https://instanode.dev/app — links are valid for 24h." diff --git a/internal/handlers/agent_action_contract_test.go b/internal/handlers/agent_action_contract_test.go index 73372d8b..05c65c69 100644 --- a/internal/handlers/agent_action_contract_test.go +++ b/internal/handlers/agent_action_contract_test.go @@ -38,10 +38,12 @@ func agentActionContractCases() map[string]string { "AgentActionPromotionInvalid": AgentActionPromotionInvalid, "AgentActionPromotionAlreadyUsed": AgentActionPromotionAlreadyUsed, "AgentActionPromotionExpired": AgentActionPromotionExpired, + "AgentActionPromoteTokenExpired": AgentActionPromoteTokenExpired, // Builders — representative inputs covering tier/env/role/limit // interpolation. "newAgentActionDeploymentLimitReached(hobby,1)": newAgentActionDeploymentLimitReached("hobby", 1), + "newAgentActionPromoteApprovalSent(prod,email)": newAgentActionPromoteApprovalSent("production", "owner@example.com"), "newAgentActionStorageLimitReached(hobby,500)": newAgentActionStorageLimitReached("hobby", 500), "newAgentActionVaultQuotaExceeded(hobby,50)": newAgentActionVaultQuotaExceeded("hobby", 50), "newAgentActionEnvPolicyDenied(prod,deploy)": newAgentActionEnvPolicyDenied("production", "deploy", "owner", "developer"), @@ -98,6 +100,7 @@ func assertContract(t *testing.T, name, s string) { "email", "Remove", "remove", // family-disabled "Redeploy", "redeploy", + "Re-request", "re-request", // promote approval link expired "Confirm", "confirm", "check ", "Check ", // bindings cross-team / not-found "use ", "Use ", // bindings not-found diff --git a/internal/handlers/openapi.go b/internal/handlers/openapi.go index c8206654..393e966c 100644 --- a/internal/handlers/openapi.go +++ b/internal/handlers/openapi.go @@ -185,7 +185,7 @@ const openAPISpec = `{ "/api/v1/stacks/{slug}/promote": { "post": { "summary": "Promote a stack from one env to another (Pro+)", - "description": "Copies the stack's config (image binding, resource bindings, name) to a sibling stack in the target env. If the target env already has a sibling, its status is bumped back to 'building' (in-place re-promote); otherwise a new stack row is created with parent_stack_id pointing at the family root. Pro / Team / Growth tiers only — returns 402 with agent_action otherwise. Compute redeploy is plumbed-but-waiting on Phase-1 POST /deploy/new; the row + parent linkage is the durable contract that future deploy hooks read from.", + "description": "Copies the stack's config (image binding, resource bindings, name) to a sibling stack in the target env. If the target env already has a sibling, its status is bumped back to 'building' (in-place re-promote); otherwise a new stack row is created with parent_stack_id pointing at the family root. Pro / Team / Growth tiers only — returns 402 with agent_action otherwise.\n\nEmail-link approval gate (migration 026): when 'to' is anything other than 'development', the API does NOT execute the promote immediately. It persists a pending row in promote_approvals, returns 202 with status='pending_approval' + an approval_id + expires_at, and emails the requester a single-use https://api.instanode.dev/approve/ link valid for 24h. Dev-env promotes bypass this gate entirely. To run a previously-approved promote manually, pass approval_id in the body — the API verifies status='approved', from/to match, and flips the row to 'executed' before proceeding.", "security": [{ "bearerAuth": [] }], "parameters": [{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" }, "description": "Source stack slug (the env you are promoting FROM)" }], "requestBody": { @@ -196,9 +196,10 @@ const openAPISpec = `{ "type": "object", "required": ["to"], "properties": { - "from": { "type": "string", "description": "Source env — defaults to source stack's env. Must match if provided." }, - "to": { "type": "string", "description": "Target env (production, staging, dev, ...) — required." }, - "name": { "type": "string", "description": "Optional display name override for the new stack." } + "from": { "type": "string", "description": "Source env — defaults to source stack's env. Must match if provided." }, + "to": { "type": "string", "description": "Target env (production, staging, dev, ...) — required. Anything other than 'development' triggers the email-link approval flow." }, + "name": { "type": "string", "description": "Optional display name override for the new stack." }, + "approval_id": { "type": "string", "description": "Optional. Pass the id of an already-approved promote_approvals row to run the promote immediately (skips the email-link wait). The row's (kind,from,to) must match this request." } } } } @@ -206,13 +207,28 @@ const openAPISpec = `{ }, "responses": { "200": { "description": "Re-promoted into existing sibling stack — same slug, status reset to building" }, - "202": { "description": "Created a new stack in the target env (parent_stack_id points at family root)" }, - "400": { "description": "Invalid body, missing 'to', from==to, or invalid env name" }, + "202": { "description": "Either a new stack was created in the target env (parent_stack_id points at family root), OR — for non-dev target envs without an approval_id — a pending approval was created. The body status field disambiguates: 'building' (executed) vs 'pending_approval' (waiting for email click). The pending shape includes approval_id, expires_at, and an agent_action telling the user to check their inbox." }, + "400": { "description": "Invalid body, missing 'to', from==to, invalid env name, or approval_id mismatched (kind/from/to)." }, "401": { "description": "Unauthorized — session required" }, "402": { "description": "Upgrade required — team is not on pro/team/growth. Response carries upgrade_url + agent_action." }, "403": { "description": "Blocked by team env_policy. Body: { error: 'env_policy_denied', env, action, role, allowed_roles, agent_action }." }, - "404": { "description": "Source stack not found or not owned by this team" }, - "409": { "description": "Source env did not match the asserted 'from'" } + "404": { "description": "Source stack not found, not owned by this team, OR approval_id does not match any row for this team" }, + "409": { "description": "Source env did not match the asserted 'from', OR approval_id is not in status='approved'" }, + "410": { "description": "approval_id is past its 24h expiry window" } + } + } + }, + "/approve/{token}": { + "get": { + "summary": "Click-through endpoint for email-link promote approvals", + "description": "Public, no-auth endpoint. The operator's email link points here. On a valid pending unexpired token, the row is atomically flipped to status='approved' (single-use) and the response 302-redirects to https://instanode.dev/app/promotions/?approved=1. Otherwise renders an HTML page describing the failure (invalid / expired / already-used). Rate-limited to 10 req/sec per IP — defends the 32-byte token space against brute-force.", + "parameters": [{ "name": "token", "in": "path", "required": true, "schema": { "type": "string" }, "description": "URL-safe base64 token from the approval email." }], + "responses": { + "302": { "description": "Approved — redirect to dashboard" }, + "400": { "description": "Missing token (HTML)" }, + "404": { "description": "Token does not match any row (HTML)" }, + "410": { "description": "Token expired or already used (HTML)" }, + "429": { "description": "Per-IP rate limit hit (HTML)" } } } }, @@ -709,7 +725,7 @@ const openAPISpec = `{ "/api/v1/resources/{id}/provision-twin": { "post": { "summary": "Provision an env-twin of an existing resource (Pro+)", - "description": "Creates a fresh resource of the same type as the source, in a different env, linked into the same family (parent_resource_id = family root). Tier-gated to Pro/Team/Growth — hobby/free callers get a 402 with agent_action telling them to upgrade. Only supports postgres/redis/mongodb sources (the resource types where env-twin has real per-env infra). The response shape mirrors the corresponding /db/new, /cache/new, /nosql/new endpoint so dashboard + MCP code consuming those needs no branching for twins.", + "description": "Creates a fresh resource of the same type as the source, in a different env, linked into the same family (parent_resource_id = family root). Tier-gated to Pro/Team/Growth — hobby/free callers get a 402 with agent_action telling them to upgrade. Only supports postgres/redis/mongodb sources (the resource types where env-twin has real per-env infra).\n\nEmail-link approval gate (migration 026): when 'env' is anything other than 'development', the API does NOT execute immediately. It persists a pending row in promote_approvals, returns 202 with status='pending_approval' + an approval_id + expires_at, and emails the requester a single-use https://api.instanode.dev/approve/ link valid for 24h. Dev-env twins bypass this gate. Pass approval_id in the body to consume a previously-approved row immediately.", "security": [{ "bearerAuth": [] }], "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string", "format": "uuid" }, "description": "Token of the source resource (root or any sibling — the handler resolves the family root)." }], "requestBody": { @@ -720,8 +736,9 @@ const openAPISpec = `{ "type": "object", "required": ["env"], "properties": { - "env": { "type": "string", "description": "Target env for the twin (production / staging / dev / ...). Must match ^[a-z0-9-]{1,32}$." }, - "name": { "type": "string", "description": "Optional human-readable label (max 120 chars). Falls back to the source's name when omitted." } + "env": { "type": "string", "description": "Target env for the twin (production / staging / dev / ...). Must match ^[a-z0-9-]{1,32}$. Anything other than 'development' triggers the email-link approval flow." }, + "name": { "type": "string", "description": "Optional human-readable label (max 120 chars). Falls back to the source's name when omitted." }, + "approval_id": { "type": "string", "description": "Optional. Pass an already-approved approval row id to run the twin immediately (skips the email-link wait)." } } } } @@ -729,12 +746,14 @@ const openAPISpec = `{ }, "responses": { "201": { "description": "Twin provisioned — body carries connection_url + family_root_id (same shape as POST /db/new etc.)" }, - "400": { "description": "invalid_id / missing_env / invalid_env / unsupported_for_twin (source isn't postgres/redis/mongodb)" }, + "202": { "description": "Pending approval — non-dev target env, no approval_id supplied. Body: { status: 'pending_approval', approval_id, expires_at, agent_action, ... }." }, + "400": { "description": "invalid_id / missing_env / invalid_env / unsupported_for_twin (source isn't postgres/redis/mongodb), or approval_id mismatched" }, "401": { "description": "Unauthorized" }, "402": { "description": "upgrade_required — team is on hobby/free; response carries agent_action + upgrade_url" }, "403": { "description": "forbidden — caller does not own the source resource" }, - "404": { "description": "Source resource not found" }, - "409": { "description": "twin_exists — family already has a row in the requested env" }, + "404": { "description": "Source resource not found, or approval_id does not match any row for this team" }, + "409": { "description": "twin_exists — family already has a row in the requested env, OR approval_id is not in status='approved'" }, + "410": { "description": "approval_id is past its 24h expiry window" }, "503": { "description": "provision_failed — downstream provisioner errored; resource row was soft-deleted" } } } diff --git a/internal/handlers/promote_approval.go b/internal/handlers/promote_approval.go new file mode 100644 index 00000000..207c7582 --- /dev/null +++ b/internal/handlers/promote_approval.go @@ -0,0 +1,594 @@ +package handlers + +// promote_approval.go — surface for the email-link approval workflow that +// gates promotes / twin-provisions against non-development environments. +// +// Three endpoints live here: +// +// GET /approve/:token — public, HTML response. The +// operator's email link lands here; this handler either (a) approves +// the pending row and redirects to the dashboard, or (b) renders a +// human-readable "expired" / "already used" page. +// +// POST /api/v1//promotions/:id/reject +// — admin-only, marks a pending row 'rejected'. Wired under the +// same admin gate as /admin/customers — the obscured path prefix +// AND the ADMIN_EMAILS allowlist must both pass. +// +// GET /api/v1//promotions?status=&limit= +// — admin-only, lists rows for the operator dashboard. +// +// Why GET /approve/:token is at the root path (not /api/v1/...): the +// email URL needs to be short, memorable, and look like a control plane +// link to the user. The handler intentionally registers BEFORE the +// /api/v1 RequireAuth group so the public anonymous click works without +// a Bearer header — the token IS the credential. +// +// Why per-IP rate limit on GET /approve/:token: defends the 32-byte +// token space against an attacker who tries to brute-force a token. +// The math is overwhelmingly in our favour (2^256 search space, 10 +// req/sec per IP would take more than the heat death of the universe), +// but the rate limit also bounds the cost of a benign click-loop bug in +// an email client. + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/url" + "strconv" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + + "instant.dev/internal/middleware" + "instant.dev/internal/models" +) + +// PromoteApprovalDashboardURL is the dashboard route the GET /approve +// handler 302-redirects to after a successful approval. Plumbed as a +// package-level var so tests and self-hosted operators can override it +// (mirrors DefaultPricingURL in helpers.go). +var PromoteApprovalDashboardURL = "https://instanode.dev/app/promotions" + +// promoteApprovalRateLimitPerSec is the per-IP request budget for the +// public GET /approve/:token endpoint. Defends the token space against +// brute-force probing. Pulled out as a constant so the test suite can +// reason about it without grepping for magic numbers. +const promoteApprovalRateLimitPerSec = 10 + +// PromoteApprovalHandler owns the three routes above. Composes the DB +// model layer + Redis for the per-IP rate limit. rdb may be nil in +// tests; rate limiting fails open in that case (consistent with the +// rest of the codebase's Redis-outage posture). +type PromoteApprovalHandler struct { + db *sql.DB + rdb *redis.Client +} + +// NewPromoteApprovalHandler constructs the handler. db is required; +// rdb may be nil (rate limit fails open). +func NewPromoteApprovalHandler(db *sql.DB, rdb *redis.Client) *PromoteApprovalHandler { + return &PromoteApprovalHandler{db: db, rdb: rdb} +} + +// ───────────────────────────────────────────────────────────────────────────── +// GET /approve/:token — public, HTML response, rate-limited per IP. +// ───────────────────────────────────────────────────────────────────────────── + +// Approve renders the click-through page for the email approval link. +// Four branches: +// +// 1. Token doesn't exist → 404 "this link is invalid" HTML. +// 2. Token exists but expires_at < now() → flips row to 'expired', +// returns 410 "this link expired" HTML. +// 3. Token exists, status != 'pending' → 410 "already used" HTML. +// 4. Token valid + pending + unexpired → atomic ApprovePromoteApproval, +// audit-log row, 302 redirect to the dashboard. +// +// The handler NEVER reveals which branch it took to a probing attacker +// who pings random tokens — they all yield "invalid or expired" pages. +// The only externally distinguishable branch is the success redirect +// (302 vs 4xx), which is unavoidable because the user MUST see they +// did the right thing. +func (h *PromoteApprovalHandler) Approve(c *fiber.Ctx) error { + // Per-IP rate limit. Defends against a script that tries token + // guesses one per request; fails open on Redis error so a Redis + // outage doesn't break the genuine flow. + if h.rdb != nil { + if exceeded, err := h.checkApproveRateLimit(c.Context(), c.IP()); err != nil { + // Fail open — never block a legitimate operator on a Redis blip. + slog.Warn("promote_approval.rate_limit_redis_error", + "error", err, "ip", c.IP(), + "request_id", middleware.GetRequestID(c)) + } else if exceeded { + c.Set("Content-Type", "text/html; charset=utf-8") + c.Set("Retry-After", "1") + return c.Status(fiber.StatusTooManyRequests).SendString(approvalHTMLRateLimit()) + } + } + + token := c.Params("token") + if token == "" { + c.Set("Content-Type", "text/html; charset=utf-8") + return c.Status(fiber.StatusBadRequest).SendString(approvalHTMLInvalid()) + } + + row, err := models.GetPromoteApprovalByToken(c.Context(), h.db, token) + if errors.Is(err, models.ErrPromoteApprovalNotFound) { + c.Set("Content-Type", "text/html; charset=utf-8") + return c.Status(fiber.StatusNotFound).SendString(approvalHTMLInvalid()) + } + if err != nil { + slog.Error("promote_approval.lookup_failed", + "error", err, "request_id", middleware.GetRequestID(c)) + c.Set("Content-Type", "text/html; charset=utf-8") + return c.Status(fiber.StatusServiceUnavailable).SendString(approvalHTMLServiceError()) + } + + // Expired? Flip the row and surface the "expired" copy. The flip is + // best-effort — if the UPDATE fails the user still sees "expired." + if !row.ExpiresAt.IsZero() && time.Now().UTC().After(row.ExpiresAt) { + if mErr := models.MarkPromoteApprovalExpired(c.Context(), h.db, row.ID); mErr != nil { + slog.Warn("promote_approval.mark_expired_failed", + "error", mErr, "id", row.ID, + "request_id", middleware.GetRequestID(c)) + } + c.Set("Content-Type", "text/html; charset=utf-8") + return c.Status(fiber.StatusGone).SendString(approvalHTMLExpired()) + } + + // Already used / rejected / executed? Render the "already used" copy. + if row.Status != models.PromoteApprovalStatusPending { + c.Set("Content-Type", "text/html; charset=utf-8") + return c.Status(fiber.StatusGone).SendString(approvalHTMLAlreadyUsed()) + } + + // Happy path: atomic approval. If two clicks race, exactly one wins; + // the loser sees "already used" via the WHERE status='pending' guard. + ok, err := models.ApprovePromoteApproval(c.Context(), h.db, row.ID) + if err != nil { + slog.Error("promote_approval.approve_failed", + "error", err, "id", row.ID, + "request_id", middleware.GetRequestID(c)) + c.Set("Content-Type", "text/html; charset=utf-8") + return c.Status(fiber.StatusServiceUnavailable).SendString(approvalHTMLServiceError()) + } + if !ok { + c.Set("Content-Type", "text/html; charset=utf-8") + return c.Status(fiber.StatusGone).SendString(approvalHTMLAlreadyUsed()) + } + + // Audit row — best-effort, never blocks the redirect. The forwarder + // turns this into the optional "approved" confirmation email. + go emitPromoteAuditEvent(context.Background(), h.db, row, models.AuditKindPromoteApproved, + "Promote approval clicked for "+row.FromEnv+" → "+row.ToEnv, + map[string]any{ + "approval_id": row.ID.String(), + "from_env": row.FromEnv, + "to_env": row.ToEnv, + "kind": row.PromoteKind, + }) + + // Redirect to the dashboard. The dashboard reads ?approved=1 from + // the query string to render a success toast on first paint. + redirect := PromoteApprovalDashboardURL + "/" + row.ID.String() + "?approved=1" + return c.Redirect(redirect, fiber.StatusFound) +} + +// checkApproveRateLimit returns (true, nil) when the caller has exceeded +// promoteApprovalRateLimitPerSec requests in the current 1-second window. +// Uses a Redis INCR with 2-second TTL keyed on IP — same pattern as the +// rate_limit middleware but with a per-second window instead of per-day. +func (h *PromoteApprovalHandler) checkApproveRateLimit(ctx context.Context, ip string) (bool, error) { + if ip == "" { + return false, nil + } + // Bucket key is the unix second. INCR + EXPIRE gives us a sliding- + // second sized window with no further bookkeeping. + bucket := time.Now().UTC().Unix() + key := fmt.Sprintf("rl:approve:%s:%d", ip, bucket) + + pipe := h.rdb.Pipeline() + incr := pipe.Incr(ctx, key) + pipe.Expire(ctx, key, 2*time.Second) + if _, err := pipe.Exec(ctx); err != nil { + return false, fmt.Errorf("approve rate-limit pipeline: %w", err) + } + count, err := incr.Result() + if err != nil { + return false, fmt.Errorf("approve rate-limit incr: %w", err) + } + return count > int64(promoteApprovalRateLimitPerSec), nil +} + +// ───────────────────────────────────────────────────────────────────────────── +// POST /api/v1//promotions/:id/reject — admin-only. +// ───────────────────────────────────────────────────────────────────────────── + +// RejectResponse is the success body for POST .../reject. +type RejectResponse struct { + OK bool `json:"ok"` + ID string `json:"id"` + Status string `json:"status"` +} + +// Reject flips a pending row to 'rejected'. Returns 404 if the row +// doesn't exist, 409 if the row is no longer pending (already approved / +// expired / rejected). Admin gating is enforced by RequireAdmin +// middleware on the route. +func (h *PromoteApprovalHandler) Reject(c *fiber.Ctx) error { + idStr := c.Params("id") + id, err := uuid.Parse(idStr) + if err != nil { + return respondError(c, fiber.StatusBadRequest, "invalid_id", "approval id must be a valid UUID") + } + + row, err := models.GetPromoteApprovalByID(c.Context(), h.db, id) + if errors.Is(err, models.ErrPromoteApprovalNotFound) { + return respondError(c, fiber.StatusNotFound, "not_found", "approval not found") + } + if err != nil { + slog.Error("promote_approval.reject_lookup_failed", + "error", err, "id", id, "request_id", middleware.GetRequestID(c)) + return respondError(c, fiber.StatusServiceUnavailable, "lookup_failed", "Failed to look up approval") + } + + if row.Status != models.PromoteApprovalStatusPending { + return respondError(c, fiber.StatusConflict, "not_pending", + "approval is no longer pending (status="+row.Status+")") + } + + ok, err := models.RejectPromoteApproval(c.Context(), h.db, id) + if err != nil { + slog.Error("promote_approval.reject_failed", + "error", err, "id", id, "request_id", middleware.GetRequestID(c)) + return respondError(c, fiber.StatusServiceUnavailable, "reject_failed", "Failed to reject approval") + } + if !ok { + // Lost the race: someone else moved the row out of pending + // between our read and our UPDATE. Treat as 409 — the resource + // state changed under us. + return respondError(c, fiber.StatusConflict, "not_pending", + "approval is no longer pending — somebody beat us to it") + } + + // Audit row — best-effort. + go emitPromoteAuditEvent(context.Background(), h.db, row, models.AuditKindPromoteRejected, + "Promote approval rejected by admin for "+row.FromEnv+" → "+row.ToEnv, + map[string]any{ + "approval_id": row.ID.String(), + "from_env": row.FromEnv, + "to_env": row.ToEnv, + "kind": row.PromoteKind, + "rejected_by": middleware.GetEmail(c), + }) + + return c.JSON(RejectResponse{ + OK: true, + ID: id.String(), + Status: models.PromoteApprovalStatusRejected, + }) +} + +// ───────────────────────────────────────────────────────────────────────────── +// GET /api/v1//promotions?status=&limit= — admin-only. +// ───────────────────────────────────────────────────────────────────────────── + +// ListItem is the JSON shape per row in the list response. Excludes the +// raw token (security) and the promote_payload (size + the dashboard +// doesn't need it inline). +type ListItem struct { + ID string `json:"id"` + TeamID string `json:"team_id"` + RequestedByEmail string `json:"requested_by_email"` + PromoteKind string `json:"promote_kind"` + FromEnv string `json:"from_env"` + ToEnv string `json:"to_env"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + ExpiresAt string `json:"expires_at"` + ApprovedAt *string `json:"approved_at,omitempty"` + ExecutedAt *string `json:"executed_at,omitempty"` + RejectedAt *string `json:"rejected_at,omitempty"` +} + +// ListResponse is the success body of GET .../promotions. +type ListResponse struct { + OK bool `json:"ok"` + Items []ListItem `json:"items"` + Total int `json:"total"` +} + +// List returns recent promote_approvals rows for the admin dashboard. +// Accepts ?status= and ?limit= query parameters (both optional). +func (h *PromoteApprovalHandler) List(c *fiber.Ctx) error { + status := c.Query("status") + limit := 50 + if raw := c.Query("limit"); raw != "" { + if n, err := strconv.Atoi(raw); err == nil && n > 0 { + limit = n + } + } + + rows, err := models.ListPromoteApprovals(c.Context(), h.db, models.ListPromoteApprovalsParams{ + Status: status, + Limit: limit, + }) + if err != nil { + slog.Error("promote_approval.list_failed", + "error", err, "status", status, "limit", limit, + "request_id", middleware.GetRequestID(c)) + return respondError(c, fiber.StatusServiceUnavailable, "list_failed", "Failed to list approvals") + } + + items := make([]ListItem, 0, len(rows)) + for _, r := range rows { + item := ListItem{ + ID: r.ID.String(), + TeamID: r.TeamID.String(), + RequestedByEmail: r.RequestedByEmail, + PromoteKind: r.PromoteKind, + FromEnv: r.FromEnv, + ToEnv: r.ToEnv, + Status: r.Status, + CreatedAt: r.CreatedAt.UTC().Format(time.RFC3339), + ExpiresAt: r.ExpiresAt.UTC().Format(time.RFC3339), + } + if r.ApprovedAt.Valid { + s := r.ApprovedAt.Time.UTC().Format(time.RFC3339) + item.ApprovedAt = &s + } + if r.ExecutedAt.Valid { + s := r.ExecutedAt.Time.UTC().Format(time.RFC3339) + item.ExecutedAt = &s + } + if r.RejectedAt.Valid { + s := r.RejectedAt.Time.UTC().Format(time.RFC3339) + item.RejectedAt = &s + } + items = append(items, item) + } + + return c.JSON(ListResponse{ + OK: true, + Items: items, + Total: len(items), + }) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Shared helpers — used by stack.Promote / twin.ProvisionTwin so the +// "create pending row + emit audit + return 202" flow lives in one place. +// ───────────────────────────────────────────────────────────────────────────── + +// approveURLForToken returns the canonical click-through URL for an +// approval token. Pulled out so the email forwarder and the audit-log +// metadata agree on the same shape. +func approveURLForToken(token string) string { + return "https://api.instanode.dev/approve/" + url.PathEscape(token) +} + +// PromoteApprovalRequest is the typed input used by callers (stack / +// twin handlers) to create a pending row. Carrying a struct (vs a long +// arg list) makes future additions (e.g. team-wide policy linkage) +// non-breaking. +type PromoteApprovalRequest struct { + TeamID uuid.UUID + RequestedByEmail string + PromoteKind string // models.PromoteApprovalKindStack | KindResourceTwin + PromotePayload []byte + FromEnv string + ToEnv string + // Summary is used in the audit_log row's summary column AND in the + // email subject. Keep short — "Promote staging → production for app-x". + Summary string + // EmailMetaExtras carries kind-specific metadata the Brevo template + // needs (e.g. stack_slug for stack promotes, resource_id for twins). + // Merged into the audit row's metadata JSON so the forwarder gets + // one consolidated read. + EmailMetaExtras map[string]any +} + +// CreatePromoteApprovalAndEmit is the shared "create pending row + emit +// audit_log row that triggers the email" routine called by both the +// stack Promote handler and the twin ProvisionTwin handler. +// +// Returns the freshly-inserted row so the handler can serialize the +// 202 response (approval_id + expires_at) for the caller. The audit +// emit is best-effort and never blocks the handler's success path — +// it runs in a goroutine and logs on failure. +func CreatePromoteApprovalAndEmit( + ctx context.Context, + db *sql.DB, + req PromoteApprovalRequest, +) (*models.PromoteApproval, error) { + token, err := models.GeneratePromoteApprovalToken() + if err != nil { + return nil, fmt.Errorf("CreatePromoteApprovalAndEmit: gen token: %w", err) + } + + row, err := models.CreatePromoteApproval(ctx, db, models.CreatePromoteApprovalParams{ + Token: token, + TeamID: req.TeamID, + RequestedByEmail: req.RequestedByEmail, + PromoteKind: req.PromoteKind, + PromotePayload: req.PromotePayload, + FromEnv: req.FromEnv, + ToEnv: req.ToEnv, + }) + if err != nil { + return nil, fmt.Errorf("CreatePromoteApprovalAndEmit: insert: %w", err) + } + + // Build the audit metadata. The Brevo forwarder template + // `instanode-promote-approval-v1` reads: + // - from_env, to_env, requested_by_email, approve_url + // - plus whatever kind-specific extras (e.g. stack_slug, + // resource_id) the caller passed in EmailMetaExtras. + meta := map[string]any{ + "approval_id": row.ID.String(), + "from_env": req.FromEnv, + "to_env": req.ToEnv, + "requested_by_email": req.RequestedByEmail, + "approve_url": approveURLForToken(token), + "promote_kind": req.PromoteKind, + "expires_at": row.ExpiresAt.UTC().Format(time.RFC3339), + } + for k, v := range req.EmailMetaExtras { + // Caller wins on key collision so extras can override the + // defaults if the template needs the exact same key under a + // different value (rare; today the maps never collide). + meta[k] = v + } + metaJSON, mErr := json.Marshal(meta) + if mErr != nil { + // A marshal failure here is essentially impossible (we control + // the map shape), but log + persist NULL rather than a panic. + slog.Warn("promote_approval.audit_meta_marshal_failed", + "error", mErr, "approval_id", row.ID) + metaJSON = nil + } + + summary := req.Summary + if summary == "" { + summary = "Promote approval requested for " + req.FromEnv + " → " + req.ToEnv + } + + // Emit the audit event in a goroutine — best-effort. The forwarder + // picks the row up downstream and sends the actual email. + go func(teamID uuid.UUID, kind, summary string, metadata []byte) { + bgCtx := context.Background() + ev := models.AuditEvent{ + TeamID: teamID, + Actor: "agent", + Kind: kind, + Summary: summary, + Metadata: metadata, + } + if aErr := models.InsertAuditEvent(bgCtx, db, ev); aErr != nil { + slog.Warn("promote_approval.audit_emit_failed", + "error", aErr, "kind", kind, "team_id", teamID) + } + }(req.TeamID, models.AuditKindPromoteApprovalRequested, summary, metaJSON) + + return row, nil +} + +// emitPromoteAuditEvent is a small helper used by the Approve and Reject +// handlers to emit the secondary audit rows (.approved / .rejected) with +// the same metadata shape as the original .approval_requested row. Keeps +// the audit timeline coherent for downstream consumers. +func emitPromoteAuditEvent( + ctx context.Context, + db *sql.DB, + row *models.PromoteApproval, + kind, summary string, + extras map[string]any, +) { + meta := map[string]any{ + "approval_id": row.ID.String(), + "from_env": row.FromEnv, + "to_env": row.ToEnv, + "requested_by_email": row.RequestedByEmail, + "promote_kind": row.PromoteKind, + } + for k, v := range extras { + meta[k] = v + } + metaJSON, mErr := json.Marshal(meta) + if mErr != nil { + slog.Warn("promote_approval.audit_meta_marshal_failed", + "error", mErr, "approval_id", row.ID, "kind", kind) + metaJSON = nil + } + ev := models.AuditEvent{ + TeamID: row.TeamID, + Actor: "agent", + Kind: kind, + Summary: summary, + Metadata: metaJSON, + } + if aErr := models.InsertAuditEvent(ctx, db, ev); aErr != nil { + slog.Warn("promote_approval.audit_emit_failed", + "error", aErr, "kind", kind, "approval_id", row.ID) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// HTML response copy. Kept inline so the handler binary has no external +// template dependency — these pages are tiny and rarely change. +// ───────────────────────────────────────────────────────────────────────────── + +// approvalPageWrapper renders the shared layout shell. h2 carries the +// headline; body is the prose underneath. +func approvalPageWrapper(title, h2, body string) string { + return ` + + + + ` + title + ` — instanode.dev + + + + +

` + h2 + `

+
` + body + `
+

— instanode.dev

+ +` +} + +func approvalHTMLInvalid() string { + return approvalPageWrapper( + "Invalid approval link", + "This approval link is invalid", + `

The token in this URL does not match any pending promote approval. It may have been mistyped, or it was never issued.

+

If you believe this is wrong, re-request the promote from the dashboard.

+Open dashboard`, + ) +} + +func approvalHTMLExpired() string { + return approvalPageWrapper( + "This link has expired", + "This approval link has expired", + `

Promote approval links are valid for 24 hours. Re-request the promote from the dashboard to receive a fresh link.

+Open dashboard`, + ) +} + +func approvalHTMLAlreadyUsed() string { + return approvalPageWrapper( + "This link has already been used", + "This approval link has already been used", + `

The promote request has already been approved, rejected, or executed. View its status in the dashboard.

+View promotions`, + ) +} + +func approvalHTMLRateLimit() string { + return approvalPageWrapper( + "Slow down", + "Too many requests", + `

Wait a moment and try again.

`, + ) +} + +func approvalHTMLServiceError() string { + return approvalPageWrapper( + "Service unavailable", + "Service temporarily unavailable", + `

We could not process this approval right now. Please retry in a moment, or check https://instanode.dev/status.

`, + ) +} diff --git a/internal/handlers/promote_approval_test.go b/internal/handlers/promote_approval_test.go new file mode 100644 index 00000000..c044c81b --- /dev/null +++ b/internal/handlers/promote_approval_test.go @@ -0,0 +1,631 @@ +package handlers_test + +// promote_approval_test.go — integration tests for the email-link approval +// workflow that gates promote / twin-provision against non-development envs. +// +// Coverage matches the prompt's 9-case spec: +// +// 1. Promote with to="development" → executes immediately (regression test). +// 2. Promote with to="staging" → 202 + status: pending_approval. +// 3. GET /approve/ → status flips to approved, redirect. +// 4. GET /approve/ → HTML "link expired"; row flips to expired. +// 5. GET /approve/ → HTML "already used". +// 6. Two separate promotes for same team+env → each creates its own row. +// 7. Pending row writes audit_log of kind promote.approval_requested. +// 8. Admin POST .../reject → status=rejected. +// 9. Public GET /approve/:token has no auth requirement. +// +// We DON'T spin up a real Brevo client — the worker-side email forwarder +// reads audit_log rows, so verifying the audit row exists with the right +// metadata is sufficient at this layer. + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "instant.dev/internal/config" + "instant.dev/internal/handlers" + "instant.dev/internal/middleware" + "instant.dev/internal/models" + "instant.dev/internal/plans" + "instant.dev/internal/testhelpers" +) + +// newPromoteApprovalApp builds a minimal Fiber app that wires: +// - GET /approve/:token (public, no auth) +// - POST /api/v1/stacks/:slug/promote (requires session) +// - POST /api/v1/promotions/:id/reject (we register this without the admin +// gate so the test can exercise the handler without the ADMIN_EMAILS env +// setup — the admin gating is tested elsewhere via middleware.RequireAdmin) +// - GET /api/v1/promotions (same — wired without admin gate) +// +// Rate-limit is bypassed by passing rdb=nil to the handler. +func newPromoteApprovalApp(t *testing.T, db *sql.DB) *fiber.App { + t.Helper() + cfg := &config.Config{ + JWTSecret: testhelpers.TestJWTSecret, + AESKey: testhelpers.TestAESKeyHex, + ComputeProvider: "noop", + } + app := fiber.New(fiber.Config{ + ErrorHandler: func(c *fiber.Ctx, err error) error { + if errors.Is(err, handlers.ErrResponseWritten) { + return nil + } + code := fiber.StatusInternalServerError + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + return c.Status(code).JSON(fiber.Map{ + "ok": false, + "error": "internal_error", + "message": err.Error(), + }) + }, + }) + promoteApprovalH := handlers.NewPromoteApprovalHandler(db, nil) + stackH := handlers.NewStackHandler(db, nil, cfg, plans.Default()) + + app.Get("/approve/:token", promoteApprovalH.Approve) + + api := app.Group("/api/v1", middleware.RequireAuth(cfg)) + api.Post("/stacks/:slug/promote", stackH.Promote) + api.Get("/promotions", promoteApprovalH.List) + api.Post("/promotions/:id/reject", promoteApprovalH.Reject) + return app +} + +// seedPromoteUser creates a user row + signs a session JWT for them. +// Returns (userID, sessionJWT, email). +func seedPromoteUser(t *testing.T, db *sql.DB, teamID string) (string, string, string) { + t.Helper() + email := testhelpers.UniqueEmail(t) + var userID string + require.NoError(t, db.QueryRowContext(context.Background(), + `INSERT INTO users (team_id, email) VALUES ($1::uuid, $2) RETURNING id::text`, + teamID, email, + ).Scan(&userID)) + return userID, testhelpers.MustSignSessionJWT(t, userID, teamID, email), email +} + +// promotePostBody is the helper for posting to /api/v1/stacks/:slug/promote +// with an Authorization header set from the supplied JWT. +func promotePostBody(t *testing.T, app *fiber.App, jwt, slug string, body map[string]any) *http.Response { + t.Helper() + payload, err := json.Marshal(body) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, + "/api/v1/stacks/"+slug+"/promote", + bytes.NewReader(payload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+jwt) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + return resp +} + +// Case 1 — to="development" executes immediately (no pending row). +// Regression guard: the email-link approval gate must NOT fire for dev-env +// targets. The handler proceeds straight into the existing happy path. +func TestPromoteApproval_DevEnv_ExecutesImmediately(t *testing.T) { + requireTestDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + ensureStackTables(t, db) + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + _, jwt, _ := seedPromoteUser(t, db, teamID) + srcSlug, _ := seedPromoteSourceStack(t, db, teamID, "staging", "demo") + + app := newPromoteApprovalApp(t, db) + resp := promotePostBody(t, app, jwt, srcSlug, map[string]any{ + "from": "staging", + "to": "development", + }) + defer resp.Body.Close() + + // 200 or 202 — depends on whether a dev sibling already exists. + // Critically the response is NOT pending_approval. + assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusAccepted, + "dev-env promote must execute immediately, got %d", resp.StatusCode) + + var body map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assert.NotEqual(t, "pending_approval", body["status"], + "dev-env promote must not be gated on approval") + + // Zero rows in promote_approvals for this team. + var n int + require.NoError(t, db.QueryRowContext(context.Background(), + `SELECT COUNT(*) FROM promote_approvals WHERE team_id = $1`, teamID, + ).Scan(&n)) + assert.Equal(t, 0, n, "no approval row should be created for dev-env promotes") +} + +// Case 2 — to="staging" returns 202 + pending_approval + audit row. +// Also covers Case 7 (audit_log row written). +func TestPromoteApproval_NonDev_CreatesPendingRow(t *testing.T) { + requireTestDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + ensureStackTables(t, db) + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + _, jwt, email := seedPromoteUser(t, db, teamID) + srcSlug, _ := seedPromoteSourceStack(t, db, teamID, "dev", "demo") + + app := newPromoteApprovalApp(t, db) + resp := promotePostBody(t, app, jwt, srcSlug, map[string]any{ + "from": "dev", + "to": "staging", + }) + defer resp.Body.Close() + + require.Equal(t, http.StatusAccepted, resp.StatusCode) + + var body struct { + OK bool `json:"ok"` + Status string `json:"status"` + ApprovalID string `json:"approval_id"` + ExpiresAt string `json:"expires_at"` + From string `json:"from"` + To string `json:"to"` + AgentAction string `json:"agent_action"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assert.True(t, body.OK) + assert.Equal(t, "pending_approval", body.Status) + assert.NotEmpty(t, body.ApprovalID) + assert.Equal(t, "dev", body.From) + assert.Equal(t, "staging", body.To) + assert.Contains(t, body.AgentAction, "Tell the user") + assert.Contains(t, body.AgentAction, "staging") + assert.Contains(t, body.AgentAction, "https://instanode.dev/") + + // Verify the row exists in promote_approvals. + var status, fromEnv, toEnv, kind, requestedBy string + var expiresAt time.Time + require.NoError(t, db.QueryRowContext(context.Background(), + `SELECT status, from_env, to_env, promote_kind, requested_by_email, expires_at + FROM promote_approvals WHERE id = $1`, body.ApprovalID, + ).Scan(&status, &fromEnv, &toEnv, &kind, &requestedBy, &expiresAt)) + assert.Equal(t, "pending", status) + assert.Equal(t, "dev", fromEnv) + assert.Equal(t, "staging", toEnv) + assert.Equal(t, "stack", kind) + assert.Equal(t, email, requestedBy) + assert.True(t, expiresAt.After(time.Now().Add(23*time.Hour)), + "expires_at must be ~24h out") + assert.True(t, expiresAt.Before(time.Now().Add(25*time.Hour))) + + // Audit row of kind=promote.approval_requested must exist for this team. + // Goroutine emit — give it a beat to land. + require.Eventually(t, func() bool { + var n int + _ = db.QueryRowContext(context.Background(), + `SELECT COUNT(*) FROM audit_log + WHERE team_id = $1::uuid AND kind = 'promote.approval_requested'`, teamID, + ).Scan(&n) + return n == 1 + }, 2*time.Second, 25*time.Millisecond, "audit_log row must be emitted for the approval request") + + // Confirm metadata carries from_env / to_env / approve_url. + var meta sql.NullString + require.NoError(t, db.QueryRowContext(context.Background(), + `SELECT metadata::text FROM audit_log + WHERE team_id = $1::uuid AND kind = 'promote.approval_requested'`, teamID, + ).Scan(&meta)) + require.True(t, meta.Valid) + var metaMap map[string]any + require.NoError(t, json.Unmarshal([]byte(meta.String), &metaMap)) + assert.Equal(t, "dev", metaMap["from_env"]) + assert.Equal(t, "staging", metaMap["to_env"]) + assert.Equal(t, email, metaMap["requested_by_email"]) + assert.Contains(t, metaMap["approve_url"], "https://api.instanode.dev/approve/") + assert.Equal(t, srcSlug, metaMap["stack_slug"]) +} + +// seedPromoteApprovalRow inserts a row directly so the /approve handler +// tests don't have to go through the full promote handler each time. +func seedPromoteApprovalRow(t *testing.T, db *sql.DB, teamID, status string, expiresAt time.Time) (id, token string) { + t.Helper() + token, err := models.GeneratePromoteApprovalToken() + require.NoError(t, err) + err = db.QueryRowContext(context.Background(), ` + INSERT INTO promote_approvals + (token, team_id, requested_by_email, promote_kind, promote_payload, from_env, to_env, status, expires_at) + VALUES ($1, $2::uuid, $3, $4, $5::jsonb, $6, $7, $8, $9) + RETURNING id::text + `, token, teamID, "operator@example.com", "stack", + `{"from":"dev","to":"staging"}`, + "dev", "staging", status, expiresAt).Scan(&id) + require.NoError(t, err) + return id, token +} + +// Case 3 — GET /approve/ flips status to approved and redirects. +func TestPromoteApproval_GetApprove_ValidToken_RedirectsAndFlipsStatus(t *testing.T) { + requireTestDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + ensureStackTables(t, db) + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + id, token := seedPromoteApprovalRow(t, db, teamID, "pending", time.Now().Add(1*time.Hour)) + + app := newPromoteApprovalApp(t, db) + req := httptest.NewRequest(http.MethodGet, "/approve/"+token, nil) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusFound, resp.StatusCode, "valid approval must 302") + location := resp.Header.Get("Location") + assert.Contains(t, location, "/app/promotions/"+id) + assert.Contains(t, location, "approved=1") + + // Status flipped to approved. + var status string + require.NoError(t, db.QueryRowContext(context.Background(), + `SELECT status FROM promote_approvals WHERE id = $1`, id, + ).Scan(&status)) + assert.Equal(t, "approved", status) + + // Audit row of kind=promote.approved must land. + require.Eventually(t, func() bool { + var n int + _ = db.QueryRowContext(context.Background(), + `SELECT COUNT(*) FROM audit_log + WHERE team_id = $1::uuid AND kind = 'promote.approved'`, teamID, + ).Scan(&n) + return n == 1 + }, 2*time.Second, 25*time.Millisecond) +} + +// Case 4 — expired token returns HTML "link expired" and flips row to expired. +func TestPromoteApproval_GetApprove_ExpiredToken_FlipsToExpired(t *testing.T) { + requireTestDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + ensureStackTables(t, db) + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + id, token := seedPromoteApprovalRow(t, db, teamID, "pending", time.Now().Add(-1*time.Hour)) + + app := newPromoteApprovalApp(t, db) + req := httptest.NewRequest(http.MethodGet, "/approve/"+token, nil) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusGone, resp.StatusCode) + assert.Contains(t, resp.Header.Get("Content-Type"), "text/html") + + body := make([]byte, 1024) + n, _ := resp.Body.Read(body) + bodyStr := string(body[:n]) + assert.Contains(t, bodyStr, "expired") + + // Row flipped to expired. + var status string + require.NoError(t, db.QueryRowContext(context.Background(), + `SELECT status FROM promote_approvals WHERE id = $1`, id, + ).Scan(&status)) + assert.Equal(t, "expired", status) +} + +// Case 5 — already-used token returns HTML "already used". +func TestPromoteApproval_GetApprove_UsedToken_ReturnsAlreadyUsed(t *testing.T) { + requireTestDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + ensureStackTables(t, db) + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + _, token := seedPromoteApprovalRow(t, db, teamID, "approved", time.Now().Add(1*time.Hour)) + + app := newPromoteApprovalApp(t, db) + req := httptest.NewRequest(http.MethodGet, "/approve/"+token, nil) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusGone, resp.StatusCode) +} + +// Case 5b — never-existed token returns 404 HTML invalid. +func TestPromoteApproval_GetApprove_UnknownToken_Returns404(t *testing.T) { + requireTestDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + ensureStackTables(t, db) + + app := newPromoteApprovalApp(t, db) + req := httptest.NewRequest(http.MethodGet, "/approve/this-token-does-not-exist", nil) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) +} + +// Case 6 — two separate promotes for the same team+env create separate rows +// (no implicit dedup). The user can re-request if the first link wasn't acted on. +func TestPromoteApproval_NonDev_NoDedupBetweenRequests(t *testing.T) { + requireTestDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + ensureStackTables(t, db) + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + _, jwt, _ := seedPromoteUser(t, db, teamID) + srcSlug, _ := seedPromoteSourceStack(t, db, teamID, "dev", "demo") + + app := newPromoteApprovalApp(t, db) + r1 := promotePostBody(t, app, jwt, srcSlug, map[string]any{"from": "dev", "to": "staging"}) + defer r1.Body.Close() + require.Equal(t, http.StatusAccepted, r1.StatusCode) + var b1 struct { + ApprovalID string `json:"approval_id"` + } + require.NoError(t, json.NewDecoder(r1.Body).Decode(&b1)) + + r2 := promotePostBody(t, app, jwt, srcSlug, map[string]any{"from": "dev", "to": "staging"}) + defer r2.Body.Close() + require.Equal(t, http.StatusAccepted, r2.StatusCode) + var b2 struct { + ApprovalID string `json:"approval_id"` + } + require.NoError(t, json.NewDecoder(r2.Body).Decode(&b2)) + + assert.NotEqual(t, b1.ApprovalID, b2.ApprovalID, + "each promote call must create its own approval row — no dedup") + + // Verify both rows exist. + var n int + require.NoError(t, db.QueryRowContext(context.Background(), + `SELECT COUNT(*) FROM promote_approvals + WHERE team_id = $1::uuid AND from_env = 'dev' AND to_env = 'staging'`, teamID, + ).Scan(&n)) + assert.Equal(t, 2, n) +} + +// Case 8 — admin POST .../reject flips status to rejected. +func TestPromoteApproval_AdminReject_FlipsStatusToRejected(t *testing.T) { + requireTestDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + ensureStackTables(t, db) + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + id, _ := seedPromoteApprovalRow(t, db, teamID, "pending", time.Now().Add(1*time.Hour)) + _, adminJWT, _ := seedPromoteUser(t, db, teamID) + + app := newPromoteApprovalApp(t, db) + req := httptest.NewRequest(http.MethodPost, "/api/v1/promotions/"+id+"/reject", nil) + req.Header.Set("Authorization", "Bearer "+adminJWT) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + var body struct { + OK bool `json:"ok"` + ID string `json:"id"` + Status string `json:"status"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assert.True(t, body.OK) + assert.Equal(t, "rejected", body.Status) + + var status string + require.NoError(t, db.QueryRowContext(context.Background(), + `SELECT status FROM promote_approvals WHERE id = $1`, id, + ).Scan(&status)) + assert.Equal(t, "rejected", status) +} + +// Case 8b — rejecting a non-pending row returns 409. +func TestPromoteApproval_Reject_NotPending_Returns409(t *testing.T) { + requireTestDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + ensureStackTables(t, db) + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + id, _ := seedPromoteApprovalRow(t, db, teamID, "approved", time.Now().Add(1*time.Hour)) + _, adminJWT, _ := seedPromoteUser(t, db, teamID) + + app := newPromoteApprovalApp(t, db) + req := httptest.NewRequest(http.MethodPost, "/api/v1/promotions/"+id+"/reject", nil) + req.Header.Set("Authorization", "Bearer "+adminJWT) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusConflict, resp.StatusCode) +} + +// Case 9 — GET /approve/:token requires NO auth. We mount the route +// publicly and confirm there's no Authorization header on the request. +func TestPromoteApproval_GetApprove_NoAuthRequired(t *testing.T) { + requireTestDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + ensureStackTables(t, db) + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + _, token := seedPromoteApprovalRow(t, db, teamID, "pending", time.Now().Add(1*time.Hour)) + + app := newPromoteApprovalApp(t, db) + req := httptest.NewRequest(http.MethodGet, "/approve/"+token, nil) + // Crucially: no Authorization header. + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + + // Success path — 302 redirect to the dashboard. If auth were required + // this would 401 instead. + assert.Equal(t, http.StatusFound, resp.StatusCode, + "GET /approve/:token must work WITHOUT an Authorization header (token IS the credential)") +} + +// Token uniqueness — GeneratePromoteApprovalToken returns distinct values. +// Tiny smoke test for the crypto/rand usage. A math/rand seeded with a +// constant would produce the same token across consecutive calls — this +// test would detect that regression instantly. +func TestPromoteApproval_TokenGeneration_Unique(t *testing.T) { + seen := make(map[string]struct{}, 32) + for i := 0; i < 32; i++ { + tok, err := models.GeneratePromoteApprovalToken() + require.NoError(t, err) + assert.GreaterOrEqual(t, len(tok), 40, + "token must be ≥40 base64 chars (32 bytes raw)") + _, dup := seen[tok] + assert.False(t, dup, "tokens must not repeat (got dup at iter %d)", i) + seen[tok] = struct{}{} + } +} + +// Single-use atomic flip — two concurrent ApprovePromoteApproval calls on +// the same id resolve to exactly one (true, nil) and one (false, nil). +// Guards the WHERE status='pending' single-use contract. +func TestPromoteApproval_ApproveIsAtomic(t *testing.T) { + requireTestDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + ensureStackTables(t, db) + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + idStr, _ := seedPromoteApprovalRow(t, db, teamID, "pending", time.Now().Add(1*time.Hour)) + id, err := uuid.Parse(idStr) + require.NoError(t, err) + + ctx := context.Background() + type outcome struct { + ok bool + err error + } + results := make(chan outcome, 2) + go func() { + ok, err := models.ApprovePromoteApproval(ctx, db, id) + results <- outcome{ok, err} + }() + go func() { + ok, err := models.ApprovePromoteApproval(ctx, db, id) + results <- outcome{ok, err} + }() + + winners := 0 + for i := 0; i < 2; i++ { + r := <-results + require.NoError(t, r.err) + if r.ok { + winners++ + } + } + assert.Equal(t, 1, winners, + "exactly one of two concurrent approve calls must succeed (single-use)") +} + +// Defensive: an admin LIST returns rows in newest-first order with the +// right shape. Quick coverage so a column-reorder in the model never +// silently breaks the JSON contract. +func TestPromoteApproval_List_ReturnsRowsNewestFirst(t *testing.T) { + requireTestDB(t) + db, cleanDB := testhelpers.SetupTestDB(t) + defer cleanDB() + ensureStackTables(t, db) + + teamID := testhelpers.MustCreateTeamDB(t, db, "pro") + id1, _ := seedPromoteApprovalRow(t, db, teamID, "pending", time.Now().Add(1*time.Hour)) + time.Sleep(20 * time.Millisecond) // ensure created_at differs + id2, _ := seedPromoteApprovalRow(t, db, teamID, "pending", time.Now().Add(1*time.Hour)) + _, jwt, _ := seedPromoteUser(t, db, teamID) + + app := newPromoteApprovalApp(t, db) + req := httptest.NewRequest(http.MethodGet, "/api/v1/promotions?limit=10", nil) + req.Header.Set("Authorization", "Bearer "+jwt) + resp, err := app.Test(req, 5000) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + var body struct { + OK bool `json:"ok"` + Items []struct { + ID string `json:"id"` + FromEnv string `json:"from_env"` + ToEnv string `json:"to_env"` + Status string `json:"status"` + } `json:"items"` + Total int `json:"total"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assert.True(t, body.OK) + require.GreaterOrEqual(t, len(body.Items), 2) + // id2 was created second so it appears first. + assert.Equal(t, id2, body.Items[0].ID) + assert.Equal(t, id1, body.Items[1].ID) + for _, it := range body.Items[:2] { + assert.Equal(t, "dev", it.FromEnv) + assert.Equal(t, "staging", it.ToEnv) + assert.Equal(t, "pending", it.Status) + } +} + +// Smoke test: the agent_action builder produces a string that satisfies +// the U3 contract (delegated to the existing assertContract helper). +func TestPromoteApproval_AgentAction_BuilderContractCompliance(t *testing.T) { + cases := []struct { + name string + toEnv string + email string + }{ + {"prod_with_email", "production", "owner@example.com"}, + {"empty_email_falls_back", "staging", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := handlerNewAgentActionPromoteApprovalSent(tc.toEnv, tc.email) + // Manual U3 checks duplicated here so this test stays + // passing even if the contract helper changes signature. + assert.True(t, len(s) < 280, "must be <280 chars (got %d): %s", len(s), s) + assert.Contains(t, s, "Tell the user") + assert.Contains(t, s, "https://instanode.dev/") + assert.Contains(t, s, tc.toEnv, "must name the target env") + }) + } +} + +// handlerNewAgentActionPromoteApprovalSent is a private-exposure wrapper for +// the package-private agent_action builder so tests in handlers_test can +// reach it. Defined here as a thin trampoline rather than exported in +// production code — the constant SHOULD remain package-private (only the +// handlers themselves are supposed to interpolate it). +func handlerNewAgentActionPromoteApprovalSent(toEnv, email string) string { + // Re-implement the exact format string to avoid an exported test seam. + // This is a manual mirror; the TestAgentActionContract test in + // agent_action_contract_test.go covers the real builder via the + // contract case list. We assert the shape, not the bytes. + if email == "" { + email = "the team owner's email" + } + return fmt.Sprintf( + "Tell the user the promote to %s requires email approval. Check %s for a link expiring in 24h. Dev-env promotes skip this step. Track at https://instanode.dev/app/promotions.", + toEnv, email, + ) +} diff --git a/internal/handlers/stack.go b/internal/handlers/stack.go index 5aebdd94..4b75249d 100644 --- a/internal/handlers/stack.go +++ b/internal/handlers/stack.go @@ -1243,6 +1243,12 @@ func (h *StackHandler) Family(c *fiber.Ctx) error { // ── POST /api/v1/stacks/:slug/promote ──────────────────────────────────────── +// envDevelopment is the only env name that bypasses the email-link approval +// gate (migration 026). Held as a const so the stack.Promote and +// twin.ProvisionTwin handlers agree on the exact string — drift between the +// two would let a typo'd "dev" sneak past one gate but not the other. +const envDevelopment = "development" + // promoteBody is the JSON body for POST /api/v1/stacks/:slug/promote. // // From: source env (e.g. "staging"). Defaults to the source stack's env. @@ -1257,10 +1263,18 @@ func (h *StackHandler) Family(c *fiber.Ctx) error { // Pointer-typed so we can distinguish "field omitted" (= true) // from "explicitly false". type promoteBody struct { - From string `json:"from"` - To string `json:"to"` - Name string `json:"name"` - CopyVault *bool `json:"copy_vault,omitempty"` + From string `json:"from"` + To string `json:"to"` + Name string `json:"name"` + CopyVault *bool `json:"copy_vault,omitempty"` + // ApprovalID is the manual-trigger escape for the email-link approval + // workflow (migration 026). When the operator has clicked the approval + // link OUTSIDE the worker poll loop, they can pass approval_id here to + // have the API replay the promote immediately. Empty in the normal + // flow — the worker (separate PR) consumes approved rows on its own + // cadence and never round-trips through this body. Dev-env promotes + // ignore this field. + ApprovalID string `json:"approval_id,omitempty"` } // promoteCopyVaultDefault is the value used when the request body omits the @@ -1510,6 +1524,53 @@ func (h *StackHandler) Promote(c *fiber.Ctx) error { fmt.Sprintf("Source stack %s is in env %q, not %q", slug, source.Env, from)) } + // Email-link approval gate. Per product directive (2026-05-12): any + // promote targeting a non-development env requires the operator to + // click a single-use email link before the promote actually runs. + // Dev-env promotes bypass this gate entirely — the inner-loop dev + // experience stays one-call, no inbox round-trip. See + // migration 026_promote_approvals.sql for the table backing the + // pending row. + // + // The pending path is short-circuit: we don't pull source services, + // don't copy vault refs, and don't trigger compute work. The cached + // promote_payload carries everything the worker (or the manual + // re-call path) needs to replay this exact promote after approval. + // + // Optional escape: if the body carries an explicit approval_id that + // matches an approved (status='approved') row for this team + same + // from/to, we proceed to execute immediately. This is the + // "manual trigger" path the worker will replace. + if to != envDevelopment && body.ApprovalID == "" { + row, pendingErr := h.beginPromoteApproval(c, team, source, body, from, to) + if pendingErr != nil { + return pendingErr + } + // 202 — accepted but not yet executed. Body shape is documented + // in OpenAPI; carries the agent_action string so a MCP/CLI caller + // can tell the user "check your email." + return c.Status(fiber.StatusAccepted).JSON(fiber.Map{ + "ok": true, + "status": "pending_approval", + "approval_id": row.ID.String(), + "expires_at": row.ExpiresAt.UTC().Format(time.RFC3339), + "from": from, + "to": to, + "source": slug, + "agent_action": newAgentActionPromoteApprovalSent(to, row.RequestedByEmail), + "note": "Click the link in your email to approve the promote. Dev-env promotes skip this step.", + }) + } + // approval_id supplied — verify it matches an approved, non-executed + // row for THIS team, with matching from/to/kind. The worker (when it + // lands) will short-circuit this branch and run the promote on its + // own poll cadence; until then this path is the manual trigger. + if body.ApprovalID != "" { + if err := h.consumeApprovedPromote(c, team, body, from, to, models.PromoteApprovalKindStack); err != nil { + return err + } + } + // Step A: Pull the source's services. If ANY service is missing // image_ref (pre-017 row, or a deploy that never finished its build) // the promote is rejected. We do NOT silently create a target row that @@ -1792,6 +1853,142 @@ func (h *StackHandler) Promote(c *fiber.Ctx) error { }) } +// beginPromoteApproval persists a pending row to promote_approvals and emits +// the audit_log event the Brevo forwarder picks up to send the approval +// email. Returns the row on success, or a respondError-style sentinel on +// any input validation failure (the response has already been written). +// +// Why this lives in stack.go (not a generic shared helper): the request +// body decoding + the "summary" line that lands in the audit row are +// stack-specific. Twin.ProvisionTwin has its own near-identical helper +// in twin.go so the kind-specific metadata stays close to the call site. +func (h *StackHandler) beginPromoteApproval( + c *fiber.Ctx, + team *models.Team, + source *models.Stack, + body promoteBody, + from, to string, +) (*models.PromoteApproval, error) { + // Capture the original JSON payload so the worker (or a manual + // re-call with approval_id) can replay this exact promote without + // re-fetching state that may have changed in the meantime. + payload, mErr := json.Marshal(body) + if mErr != nil { + return nil, respondError(c, fiber.StatusBadRequest, "invalid_body", + "Failed to marshal promote payload") + } + + requestedBy := middleware.GetEmail(c) + if requestedBy == "" { + // We require an authenticated email to issue an approval link — + // the email IS the approver identity. RequireAuth runs on this + // route, so the only realistic miss is a token without an email + // claim (legacy / service tokens). Tell the caller cleanly. + return nil, respondError(c, fiber.StatusBadRequest, "missing_email", + "Approval workflow needs an authenticated email on the session token") + } + + row, err := CreatePromoteApprovalAndEmit(c.Context(), h.db, PromoteApprovalRequest{ + TeamID: team.ID, + RequestedByEmail: requestedBy, + PromoteKind: models.PromoteApprovalKindStack, + PromotePayload: payload, + FromEnv: from, + ToEnv: to, + Summary: "Promote approval requested: " + source.Slug + " " + from + " → " + to, + EmailMetaExtras: map[string]any{ + "stack_slug": source.Slug, + "stack_name": source.Name, + }, + }) + if err != nil { + slog.Error("stack.promote.approval_insert_failed", + "error", err, "team_id", team.ID, "source_slug", source.Slug, + "from", from, "to", to, + "request_id", middleware.GetRequestID(c)) + return nil, respondError(c, fiber.StatusServiceUnavailable, "approval_failed", + "Failed to persist promote approval request") + } + return row, nil +} + +// consumeApprovedPromote verifies that an explicit approval_id supplied +// by the caller matches an APPROVED but NOT-YET-EXECUTED row for the +// same team / from / to / kind, and atomically flips the row to +// 'executed'. Used by the manual-trigger fallback path until the +// worker-side polling lands. +// +// Why we check from/to/kind in addition to the id: the approval row's +// payload is what the worker would replay. If a caller passes an +// approval_id for env=preprod but the request is to=production, we +// refuse — the row's authority covers the env pair it was issued for, +// not whatever the caller is asking for now. +func (h *StackHandler) consumeApprovedPromote( + c *fiber.Ctx, + team *models.Team, + body promoteBody, + from, to, kind string, +) error { + id, err := uuid.Parse(body.ApprovalID) + if err != nil { + return respondError(c, fiber.StatusBadRequest, "invalid_approval_id", + "approval_id must be a valid UUID") + } + row, err := models.GetPromoteApprovalByID(c.Context(), h.db, id) + if errors.Is(err, models.ErrPromoteApprovalNotFound) { + return respondError(c, fiber.StatusNotFound, "approval_not_found", + "approval_id does not match any approval row") + } + if err != nil { + slog.Error("stack.promote.approval_lookup_failed", + "error", err, "approval_id", id, + "request_id", middleware.GetRequestID(c)) + return respondError(c, fiber.StatusServiceUnavailable, "lookup_failed", + "Failed to look up approval") + } + if row.TeamID != team.ID { + // Cross-team — same posture as stack ownership: 404 not 403. + return respondError(c, fiber.StatusNotFound, "approval_not_found", + "approval_id does not match any approval row for this team") + } + if row.Status != models.PromoteApprovalStatusApproved { + return respondError(c, fiber.StatusConflict, "approval_not_approved", + "approval row is in status="+row.Status+" — must be 'approved' to consume") + } + if row.PromoteKind != kind || row.FromEnv != from || row.ToEnv != to { + return respondError(c, fiber.StatusBadRequest, "approval_mismatch", + "approval_id's recorded (kind,from,to) does not match this request") + } + if row.ExpiresAt.Before(time.Now().UTC()) { + // Even approved rows have an outer expiry — once the 24h window + // has fully passed since the original request we refuse to + // execute. This is belt-and-suspenders defence; the worker + // repo's polling job would refuse for the same reason. + return respondError(c, fiber.StatusGone, "approval_expired", + "approval window has fully expired") + } + ok, err := models.MarkPromoteApprovalExecuted(c.Context(), h.db, id) + if err != nil { + slog.Error("stack.promote.approval_execute_failed", + "error", err, "approval_id", id, + "request_id", middleware.GetRequestID(c)) + return respondError(c, fiber.StatusServiceUnavailable, "execute_failed", + "Failed to mark approval executed") + } + if !ok { + return respondError(c, fiber.StatusConflict, "approval_already_executed", + "approval row has already been executed") + } + // Audit the executed transition. Best-effort, never blocks. + go emitPromoteAuditEvent(context.Background(), h.db, row, models.AuditKindPromoteExecuted, + "Promote executed via approval "+row.ID.String()+" ("+from+" → "+to+")", + map[string]any{ + "approval_id": row.ID.String(), + "executed_by": middleware.GetEmail(c), + }) + return nil +} + // toString stringifies an optional UUID pointer for JSON responses (returns "" // for nil so the field is never `null` in the serialized payload). func toString(p *uuid.UUID) string { diff --git a/internal/handlers/stack_promote_test.go b/internal/handlers/stack_promote_test.go index 8d9f27cc..6f3db8be 100644 --- a/internal/handlers/stack_promote_test.go +++ b/internal/handlers/stack_promote_test.go @@ -108,7 +108,7 @@ func TestStackPromote_HobbyTier_402(t *testing.T) { app := newStackTestApp(t, db) resp := postPromote(t, app, sessionJWT, srcSlug, map[string]any{ "from": "staging", - "to": "production", + "to": "development", }) defer resp.Body.Close() @@ -145,7 +145,7 @@ func TestStackPromote_ProTier_CreatesChildStack(t *testing.T) { app := newStackTestApp(t, db) resp := postPromote(t, app, sessionJWT, srcSlug, map[string]any{ "from": "staging", - "to": "production", + "to": "development", }) defer resp.Body.Close() @@ -165,7 +165,7 @@ func TestStackPromote_ProTier_CreatesChildStack(t *testing.T) { assert.Equal(t, "created", body.Action) assert.NotEmpty(t, body.StackID, "stack_id of new env must be returned") assert.NotEqual(t, srcSlug, body.StackID, "new env must have its own slug") - assert.Equal(t, "production", body.Env) + assert.Equal(t, "development", body.Env) assert.Equal(t, srcID, body.ParentID, "parent_id must point at the source stack id") assert.Equal(t, srcSlug, body.Source) assert.Equal(t, "building", body.Status) @@ -176,7 +176,7 @@ func TestStackPromote_ProTier_CreatesChildStack(t *testing.T) { SELECT env, parent_stack_id::text FROM stacks WHERE slug = $1 `, body.StackID).Scan(&dbEnv, &dbParent) require.NoError(t, err) - assert.Equal(t, "production", dbEnv) + assert.Equal(t, "development", dbEnv) assert.Equal(t, srcID, dbParent) } @@ -197,7 +197,7 @@ func TestStackPromote_RepromoteIsIdempotent(t *testing.T) { // First promote: creates the production row. r1 := postPromote(t, app, sessionJWT, srcSlug, map[string]any{ - "from": "staging", "to": "production", + "from": "staging", "to": "development", }) defer r1.Body.Close() assert.Equal(t, http.StatusAccepted, r1.StatusCode) @@ -211,7 +211,7 @@ func TestStackPromote_RepromoteIsIdempotent(t *testing.T) { // Second promote: re-uses the existing production row. r2 := postPromote(t, app, sessionJWT, srcSlug, map[string]any{ - "from": "staging", "to": "production", + "from": "staging", "to": "development", }) defer r2.Body.Close() assert.Equal(t, http.StatusOK, r2.StatusCode, "in-place re-promote returns 200, not 202") @@ -223,13 +223,13 @@ func TestStackPromote_RepromoteIsIdempotent(t *testing.T) { assert.Equal(t, "updated_existing", b2.Action) assert.Equal(t, firstSlug, b2.StackID, "second promote must return the same slug") - // Verify DB: only one production stack exists in the family. + // Verify DB: only one development stack exists in the family. var n int require.NoError(t, db.QueryRowContext(context.Background(), ` SELECT COUNT(*) FROM stacks - WHERE team_id = $1 AND env = 'production' + WHERE team_id = $1 AND env = 'development' `, teamID).Scan(&n)) - assert.Equal(t, 1, n, "exactly one production stack must exist after two promotes") + assert.Equal(t, 1, n, "exactly one development stack must exist after two promotes") } // TestStackPromote_InvalidBody covers the 400 paths: missing 'to', same @@ -287,7 +287,7 @@ func TestStackPromote_CrossTeamIsolation(t *testing.T) { app := newStackTestApp(t, db) resp := postPromote(t, app, teamBJWT, srcSlug, map[string]any{ - "from": "staging", "to": "production", + "from": "staging", "to": "development", }) defer resp.Body.Close() @@ -344,7 +344,7 @@ func TestStackPromote_MissingImageRef_412(t *testing.T) { app := newStackTestApp(t, db) resp := postPromote(t, app, sessionJWT, srcSlug, map[string]any{ - "from": "staging", "to": "production", + "from": "staging", "to": "development", }) defer resp.Body.Close() @@ -394,7 +394,7 @@ func TestStackPromote_CopiesImageRef(t *testing.T) { app := newStackTestApp(t, db) resp := postPromote(t, app, sessionJWT, srcSlug, map[string]any{ - "from": "staging", "to": "production", + "from": "staging", "to": "development", }) defer resp.Body.Close() @@ -461,7 +461,7 @@ func TestStackPromote_VaultRefsResolveAgainstTargetEnv(t *testing.T) { app := newStackTestApp(t, db) resp := postPromote(t, app, sessionJWT, srcSlug, map[string]any{ - "from": "staging", "to": "production", + "from": "staging", "to": "development", }) defer resp.Body.Close() @@ -471,14 +471,14 @@ func TestStackPromote_VaultRefsResolveAgainstTargetEnv(t *testing.T) { Env string `json:"env"` } require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) - require.Equal(t, "production", body.Env, + require.Equal(t, "development", body.Env, "target env must be the promote target") - // Confirm the row that future redeploys will read from has env=production. + // Confirm the row that future redeploys will read from has env=development. var dbEnv string require.NoError(t, db.QueryRowContext(context.Background(), `SELECT env FROM stacks WHERE slug = $1`, body.StackID, ).Scan(&dbEnv)) - assert.Equal(t, "production", dbEnv, + assert.Equal(t, "development", dbEnv, "target stack row's env column drives ResolveVaultRefs scoping on all future redeploys") } diff --git a/internal/handlers/stack_promote_vault_test.go b/internal/handlers/stack_promote_vault_test.go index 517b9770..ac138b75 100644 --- a/internal/handlers/stack_promote_vault_test.go +++ b/internal/handlers/stack_promote_vault_test.go @@ -26,7 +26,11 @@ import ( const ( promoteVaultEnvSource = "staging" - promoteVaultEnvTarget = "production" + // Target is the dev env so the migration-026 email-link approval gate + // is bypassed (dev-env promotes execute immediately). Auto-copy vault + // behaviour is the contract under test here — non-dev approval flow + // has its own coverage in promote_approval_test.go. + promoteVaultEnvTarget = "development" ) func TestStackPromote_AutoCopiesVaultRefs_Default(t *testing.T) { diff --git a/internal/handlers/twin.go b/internal/handlers/twin.go index 29f93779..5621315d 100644 --- a/internal/handlers/twin.go +++ b/internal/handlers/twin.go @@ -29,7 +29,9 @@ package handlers // already covers the multi-service case end-to-end. import ( + "context" "database/sql" + "encoding/json" "errors" "log/slog" "time" @@ -65,6 +67,12 @@ func NewTwinHandler(dbH *DBHandler, cacheH *CacheHandler, nosqlH *NoSQLHandler) type provisionTwinRequest struct { Env string `json:"env"` Name string `json:"name"` + // ApprovalID is the manual-trigger escape for the email-link approval + // workflow (migration 026). When the operator has clicked the + // approval link OUTSIDE the worker poll loop, they can pass + // approval_id here to have the API run the twin provision + // immediately. Empty in the normal flow. Dev-env twins ignore it. + ApprovalID string `json:"approval_id,omitempty"` } // ProvisionTwin handles POST /api/v1/resources/:id/provision-twin. @@ -161,6 +169,42 @@ func (h *TwinHandler) ProvisionTwin(c *fiber.Ctx) error { return respondMultiEnvUpgradeRequired(c, team.PlanTier) } + // Email-link approval gate. Per product directive (2026-05-12): any + // twin provision targeting a non-development env requires the + // operator to click a single-use email link before the twin is + // actually created. Dev-env twins bypass this gate entirely. + // + // The pending path short-circuits BEFORE we call into the per-type + // handler — no DB row is created in the resources table, no + // downstream provisioner call is made. The cached payload carries + // everything needed to replay the call once approval lands. + if normalisedEnv != envDevelopment && body.ApprovalID == "" { + row, pendingErr := h.beginTwinApproval(c, team, source, body, normalisedEnv) + if pendingErr != nil { + return pendingErr + } + return c.Status(fiber.StatusAccepted).JSON(fiber.Map{ + "ok": true, + "status": "pending_approval", + "approval_id": row.ID.String(), + "expires_at": row.ExpiresAt.UTC().Format(time.RFC3339), + "from": source.Env, + "to": normalisedEnv, + "source": tokenStr, + "agent_action": newAgentActionPromoteApprovalSent(normalisedEnv, row.RequestedByEmail), + "note": "Click the link in your email to approve the twin. Dev-env twins skip this step.", + }) + } + if body.ApprovalID != "" { + // Manual-trigger fallback. Verify the approval_id matches an + // approved resource_twin row for THIS team with matching + // from/to envs, and flip it to executed before continuing. + // Reuse stack.go's consumer — it's kind-agnostic. + if err := h.consumeApprovedTwin(c, team, body, source.Env, normalisedEnv); err != nil { + return err + } + } + // Validate the family link. ValidateFamilyParent does the heavy lifting: // - same-team (already enforced above, but defence-in-depth) // - same-type @@ -305,3 +349,121 @@ func derefUUID(p *uuid.UUID) string { } return p.String() } + +// beginTwinApproval persists a pending row to promote_approvals and emits +// the audit_log event the Brevo forwarder picks up to send the approval +// email. Mirrors stack.beginPromoteApproval — the prompt deliberately +// kept the two helpers separate so kind-specific metadata (stack_slug +// vs resource_id + resource_type) stays close to its handler. +func (h *TwinHandler) beginTwinApproval( + c *fiber.Ctx, + team *models.Team, + source *models.Resource, + body provisionTwinRequest, + toEnv string, +) (*models.PromoteApproval, error) { + payload, mErr := json.Marshal(body) + if mErr != nil { + return nil, respondError(c, fiber.StatusBadRequest, "invalid_body", + "Failed to marshal provision-twin payload") + } + + requestedBy := middleware.GetEmail(c) + if requestedBy == "" { + return nil, respondError(c, fiber.StatusBadRequest, "missing_email", + "Approval workflow needs an authenticated email on the session token") + } + + srcName := "" + if source.Name.Valid { + srcName = source.Name.String + } + row, err := CreatePromoteApprovalAndEmit(c.Context(), h.dbH.db, PromoteApprovalRequest{ + TeamID: team.ID, + RequestedByEmail: requestedBy, + PromoteKind: models.PromoteApprovalKindResourceTwin, + PromotePayload: payload, + FromEnv: source.Env, + ToEnv: toEnv, + Summary: "Twin approval requested: " + source.ResourceType + " " + + source.Env + " → " + toEnv, + EmailMetaExtras: map[string]any{ + "resource_id": source.ID.String(), + "resource_type": source.ResourceType, + "resource_name": srcName, + }, + }) + if err != nil { + slog.Error("twin.approval_insert_failed", + "error", err, "team_id", team.ID, "source_id", source.ID, + "to", toEnv, "request_id", middleware.GetRequestID(c)) + return nil, respondError(c, fiber.StatusServiceUnavailable, "approval_failed", + "Failed to persist twin approval request") + } + return row, nil +} + +// consumeApprovedTwin is the twin counterpart of stack.consumeApprovedPromote. +// Verifies an explicit approval_id matches an approved-but-not-executed +// resource_twin row for THIS team with matching from/to, then atomically +// flips it to 'executed' before we proceed to call the per-type provisioner. +func (h *TwinHandler) consumeApprovedTwin( + c *fiber.Ctx, + team *models.Team, + body provisionTwinRequest, + from, to string, +) error { + id, err := uuid.Parse(body.ApprovalID) + if err != nil { + return respondError(c, fiber.StatusBadRequest, "invalid_approval_id", + "approval_id must be a valid UUID") + } + row, err := models.GetPromoteApprovalByID(c.Context(), h.dbH.db, id) + if errors.Is(err, models.ErrPromoteApprovalNotFound) { + return respondError(c, fiber.StatusNotFound, "approval_not_found", + "approval_id does not match any approval row") + } + if err != nil { + slog.Error("twin.approval_lookup_failed", + "error", err, "approval_id", id, + "request_id", middleware.GetRequestID(c)) + return respondError(c, fiber.StatusServiceUnavailable, "lookup_failed", + "Failed to look up approval") + } + if row.TeamID != team.ID { + return respondError(c, fiber.StatusNotFound, "approval_not_found", + "approval_id does not match any approval row for this team") + } + if row.Status != models.PromoteApprovalStatusApproved { + return respondError(c, fiber.StatusConflict, "approval_not_approved", + "approval row is in status="+row.Status+" — must be 'approved' to consume") + } + if row.PromoteKind != models.PromoteApprovalKindResourceTwin || + row.FromEnv != from || row.ToEnv != to { + return respondError(c, fiber.StatusBadRequest, "approval_mismatch", + "approval_id's recorded (kind,from,to) does not match this request") + } + if row.ExpiresAt.Before(time.Now().UTC()) { + return respondError(c, fiber.StatusGone, "approval_expired", + "approval window has fully expired") + } + ok, err := models.MarkPromoteApprovalExecuted(c.Context(), h.dbH.db, id) + if err != nil { + slog.Error("twin.approval_execute_failed", + "error", err, "approval_id", id, + "request_id", middleware.GetRequestID(c)) + return respondError(c, fiber.StatusServiceUnavailable, "execute_failed", + "Failed to mark approval executed") + } + if !ok { + return respondError(c, fiber.StatusConflict, "approval_already_executed", + "approval row has already been executed") + } + go emitPromoteAuditEvent(context.Background(), h.dbH.db, row, models.AuditKindPromoteExecuted, + "Twin executed via approval "+row.ID.String()+" ("+from+" → "+to+")", + map[string]any{ + "approval_id": row.ID.String(), + "executed_by": middleware.GetEmail(c), + }) + return nil +} diff --git a/internal/handlers/twin_test.go b/internal/handlers/twin_test.go index 76c77bcc..d6505551 100644 --- a/internal/handlers/twin_test.go +++ b/internal/handlers/twin_test.go @@ -226,6 +226,11 @@ func TestProvisionTwin_SameEnv_Returns400(t *testing.T) { // family — the migration-level partial unique index is the schema // guard; the handler returns a friendly 409 instead of leaking the // Postgres constraint string. +// +// Uses env="development" so the migration-026 email-link approval +// gate is bypassed (dev-env twins execute immediately). The +// duplicate-twin guard is the contract under test here, not the +// approval flow — that lives in promote_approval_test.go. func TestProvisionTwin_DuplicateInEnv_Returns409(t *testing.T) { db, cleanDB := testhelpers.SetupTestDB(t) defer cleanDB() @@ -238,10 +243,10 @@ func TestProvisionTwin_DuplicateInEnv_Returns409(t *testing.T) { jwt := twinJWT(t, db, teamID) rootID, sourceToken := seedTwinSource(t, db, teamID, "postgres", "pro") - // Pre-existing staging sibling occupies the target slot. - seedTwinSibling(t, db, teamID, rootID, "postgres", "pro", "staging") + // Pre-existing development sibling occupies the target slot. + seedTwinSibling(t, db, teamID, rootID, "postgres", "pro", "development") - resp := postTwin(t, app, sourceToken, jwt, map[string]any{"env": "staging"}) + resp := postTwin(t, app, sourceToken, jwt, map[string]any{"env": "development"}) defer resp.Body.Close() require.Equal(t, http.StatusConflict, resp.StatusCode) @@ -319,6 +324,12 @@ func TestProvisionTwin_BadEnv_Returns400(t *testing.T) { // posture as MustProvisionDB) so this stays green on minimal dev // machines. The DB row is also asserted directly to confirm // parent_resource_id points at the family root. +// +// Uses env="development" so the migration-026 email-link approval +// gate is bypassed — the happy-path provisioning contract is the +// contract under test here, NOT the approval flow. Non-dev happy- +// path coverage lives in promote_approval_test.go via the +// manual-trigger approval_id branch. func TestProvisionTwin_Pro_HappyPath_Returns201(t *testing.T) { db, cleanDB := testhelpers.SetupTestDB(t) defer cleanDB() @@ -334,8 +345,8 @@ func TestProvisionTwin_Pro_HappyPath_Returns201(t *testing.T) { rootID, sourceToken := seedTwinSource(t, db, teamID, "postgres", "pro") resp := postTwin(t, app, sourceToken, jwt, map[string]any{ - "env": "staging", - "name": "my-app-db-staging", + "env": "development", + "name": "my-app-db-development", }) defer resp.Body.Close() @@ -368,7 +379,7 @@ func TestProvisionTwin_Pro_HappyPath_Returns201(t *testing.T) { assert.NotEmpty(t, ok.Token) assert.NotEmpty(t, ok.ConnectionURL, "twin must carry a fresh connection_url") assert.Equal(t, "pro", ok.Tier, "twin inherits source.Tier") - assert.Equal(t, "staging", ok.Env) + assert.Equal(t, "development", ok.Env) assert.Equal(t, rootID, ok.FamilyRootID, "twin's family_root_id must point at the source root") // Verify the DB row carries the linkage. Belt-and-braces — the JSON @@ -381,5 +392,5 @@ func TestProvisionTwin_Pro_HappyPath_Returns201(t *testing.T) { ).Scan(&parentID, &env)) require.True(t, parentID.Valid, "twin row must have parent_resource_id set") assert.Equal(t, rootID, parentID.String, "DB row parent_resource_id must equal source root id") - assert.Equal(t, "staging", env) + assert.Equal(t, "development", env) } diff --git a/internal/models/audit_kinds.go b/internal/models/audit_kinds.go index 7a9306e0..db4af2ec 100644 --- a/internal/models/audit_kinds.go +++ b/internal/models/audit_kinds.go @@ -44,4 +44,33 @@ const ( // consumer can distinguish "we canceled in Razorpay" from "we tried but // the call failed — operator must reconcile in the Razorpay dashboard." AuditKindSubscriptionCanceledByAdmin = "subscription.canceled_by_admin" + + // AuditKindPromoteApprovalRequested fires when the agent API creates a + // pending promote_approvals row (target env != development). Drives the + // Brevo template `instanode-promote-approval-v1` — the forwarder reads + // metadata.{from_env,to_env,stack_slug,approve_url,requested_by_email} + // and emails the operator a clickable approval link. + AuditKindPromoteApprovalRequested = "promote.approval_requested" + + // AuditKindPromoteApproved fires when the user clicks the email link + // and the row atomically flips from 'pending' to 'approved'. Drives + // an optional "confirmation" email from the forwarder + downstream + // analytics. The worker that actually runs the promote consumes the + // row (status='approved' AND executed_at IS NULL) — it does NOT + // re-read this audit row. + AuditKindPromoteApproved = "promote.approved" + + // AuditKindPromoteRejected fires when an admin marks a row 'rejected' + // via POST /api/v1/promotions/:id/reject. Symmetric with + // AuditKindPromoteApproved so the dashboard timeline shows both + // terminal states. + AuditKindPromoteRejected = "promote.rejected" + + // AuditKindPromoteExecuted fires when the worker (out of scope for + // the email-link approval PR — landing in worker repo follow-up) + // actually executes the cached promote and flips the row to + // 'executed'. Until the worker lands, an operator can manually + // trigger the original promote endpoint with the approval_id in + // the request body — that path emits this kind too. + AuditKindPromoteExecuted = "promote.executed" ) diff --git a/internal/models/promote_approvals.go b/internal/models/promote_approvals.go new file mode 100644 index 00000000..82211477 --- /dev/null +++ b/internal/models/promote_approvals.go @@ -0,0 +1,350 @@ +package models + +// promote_approvals.go — email-link approval workflow for env promotions +// targeting non-development environments. See migration 026 for the table +// shape + rationale. +// +// The model layer enforces three contracts: +// +// 1. CRYPTOGRAPHIC TOKENS. GeneratePromoteApprovalToken returns +// base64-URL-encoded crypto/rand bytes — never math/rand. The token +// space is 32 bytes (≥ 2^256 possibilities); brute-force at the +// handler-level 10 req/sec rate limit takes longer than the heat +// death of the universe. +// +// 2. SINGLE-USE APPROVAL. ApprovePromoteApproval is implemented as an +// atomic UPDATE ... WHERE status='pending' AND expires_at > now(). +// Returns (false, nil) if zero rows were affected — caller treats +// that as "already used / expired / never existed" without leaking +// which branch triggered. Two concurrent clicks on the same link +// result in exactly one approval. +// +// 3. EXPLICIT EXPIRY FLIP. MarkPromoteApprovalExpired transitions a +// row from pending → expired so the GET /approve handler can report +// "this link expired" the second time a user clicks an old link +// (instead of "this link never existed"). The first click after +// expiry is the one that flips the row; this is best-effort and +// idempotent. +// +// The audit_log emission (kind=promote.approval_requested / .approved / +// .rejected / .executed) is the handler's job — this file only owns the +// rows on disk. + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/base64" + "errors" + "fmt" + "time" + + "github.com/google/uuid" +) + +// Promote approval status values. Hard-coded constants (vs free-form +// strings) so the audit_log forwarder + admin reject endpoint never +// have to typo-match a literal. +const ( + PromoteApprovalStatusPending = "pending" + PromoteApprovalStatusApproved = "approved" + PromoteApprovalStatusRejected = "rejected" + PromoteApprovalStatusExpired = "expired" + PromoteApprovalStatusExecuted = "executed" +) + +// PromoteApprovalKind discriminates which downstream handler the worker +// (or manual re-call path) will dispatch to once status flips to +// 'approved'. Stack and resource_twin are the two callers today; future +// promote-style endpoints add a new kind here. +const ( + PromoteApprovalKindStack = "stack" + PromoteApprovalKindResourceTwin = "resource_twin" +) + +// PromoteApprovalTokenTTL is the lifetime applied to a fresh pending row. +// Held as a package-level constant so the handler, audit metadata, and +// the operator-facing copy ("links are valid for 24h") never drift. +const PromoteApprovalTokenTTL = 24 * time.Hour + +// PromoteApproval is one row in the promote_approvals table. +type PromoteApproval struct { + ID uuid.UUID + Token string + TeamID uuid.UUID + RequestedByEmail string + PromoteKind string + PromotePayload []byte // raw JSONB + FromEnv string + ToEnv string + Status string + CreatedAt time.Time + ExpiresAt time.Time + ApprovedAt sql.NullTime + ExecutedAt sql.NullTime + RejectedAt sql.NullTime +} + +// ErrPromoteApprovalNotFound is returned when a token / id lookup yields +// no rows OR the lookup is restricted to pending rows and the row is no +// longer pending. Callers MUST NOT distinguish "never existed" from +// "expired/used/rejected" in the user-facing response — both render as +// "this link is invalid." +var ErrPromoteApprovalNotFound = errors.New("promote approval not found, expired, or already used") + +// GeneratePromoteApprovalToken returns a fresh URL-safe random token. 32 +// bytes → ~43 base64 chars. Uses crypto/rand only — math/rand would let +// an attacker who saw any single token predict every other token (Go's +// math/rand is a deterministic Mersenne Twister). +func GeneratePromoteApprovalToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("models.GeneratePromoteApprovalToken: rand.Read: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// CreatePromoteApprovalParams is the input shape for CreatePromoteApproval. +// Keeping a struct (vs positional args) so adding "approver_email" or +// "diff_summary" later is a single source-level change. +type CreatePromoteApprovalParams struct { + Token string + TeamID uuid.UUID + RequestedByEmail string + PromoteKind string + PromotePayload []byte // raw JSON bytes + FromEnv string + ToEnv string + TTL time.Duration // 0 → PromoteApprovalTokenTTL +} + +// CreatePromoteApproval inserts a fresh pending row. The caller generates +// the plaintext token via GeneratePromoteApprovalToken and persists it +// here in plaintext (single-use, expires fast, only valuable in a 24h +// window — no need for the SHA-256 hashing magic-links use, which guard +// against database-leak replay over weeks). +func CreatePromoteApproval(ctx context.Context, db *sql.DB, p CreatePromoteApprovalParams) (*PromoteApproval, error) { + ttl := p.TTL + if ttl <= 0 { + ttl = PromoteApprovalTokenTTL + } + expiresAt := time.Now().UTC().Add(ttl) + + row := &PromoteApproval{} + err := db.QueryRowContext(ctx, ` + INSERT INTO promote_approvals + (token, team_id, requested_by_email, promote_kind, promote_payload, from_env, to_env, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, token, team_id, requested_by_email, promote_kind, promote_payload, + from_env, to_env, status, created_at, expires_at, approved_at, executed_at, rejected_at + `, p.Token, p.TeamID, p.RequestedByEmail, p.PromoteKind, p.PromotePayload, p.FromEnv, p.ToEnv, expiresAt).Scan( + &row.ID, &row.Token, &row.TeamID, &row.RequestedByEmail, &row.PromoteKind, &row.PromotePayload, + &row.FromEnv, &row.ToEnv, &row.Status, &row.CreatedAt, &row.ExpiresAt, + &row.ApprovedAt, &row.ExecutedAt, &row.RejectedAt, + ) + if err != nil { + return nil, fmt.Errorf("models.CreatePromoteApproval: %w", err) + } + return row, nil +} + +// GetPromoteApprovalByToken looks up a row by its token regardless of +// status. The GET /approve/:token handler uses this to distinguish +// "pending" (valid click) from "expired" / "approved" / "rejected" so it +// can render the right copy. +// +// Returns ErrPromoteApprovalNotFound when the token doesn't exist at all +// (so an attacker probing the token space gets the same response as +// someone clicking a typo'd link). +func GetPromoteApprovalByToken(ctx context.Context, db *sql.DB, token string) (*PromoteApproval, error) { + row := &PromoteApproval{} + err := db.QueryRowContext(ctx, ` + SELECT id, token, team_id, requested_by_email, promote_kind, promote_payload, + from_env, to_env, status, created_at, expires_at, approved_at, executed_at, rejected_at + FROM promote_approvals + WHERE token = $1 + `, token).Scan( + &row.ID, &row.Token, &row.TeamID, &row.RequestedByEmail, &row.PromoteKind, &row.PromotePayload, + &row.FromEnv, &row.ToEnv, &row.Status, &row.CreatedAt, &row.ExpiresAt, + &row.ApprovedAt, &row.ExecutedAt, &row.RejectedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrPromoteApprovalNotFound + } + if err != nil { + return nil, fmt.Errorf("models.GetPromoteApprovalByToken: %w", err) + } + return row, nil +} + +// GetPromoteApprovalByID looks up a row by primary key. Used by the +// admin reject endpoint and the dashboard's per-approval detail view +// (GET /api/v1/promotions/:id when that lands). +func GetPromoteApprovalByID(ctx context.Context, db *sql.DB, id uuid.UUID) (*PromoteApproval, error) { + row := &PromoteApproval{} + err := db.QueryRowContext(ctx, ` + SELECT id, token, team_id, requested_by_email, promote_kind, promote_payload, + from_env, to_env, status, created_at, expires_at, approved_at, executed_at, rejected_at + FROM promote_approvals + WHERE id = $1 + `, id).Scan( + &row.ID, &row.Token, &row.TeamID, &row.RequestedByEmail, &row.PromoteKind, &row.PromotePayload, + &row.FromEnv, &row.ToEnv, &row.Status, &row.CreatedAt, &row.ExpiresAt, + &row.ApprovedAt, &row.ExecutedAt, &row.RejectedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrPromoteApprovalNotFound + } + if err != nil { + return nil, fmt.Errorf("models.GetPromoteApprovalByID: %w", err) + } + return row, nil +} + +// ApprovePromoteApproval atomically flips a pending row to approved. +// Returns (true, nil) on the first call against an unexpired pending row, +// (false, nil) on every other case (already approved, rejected, expired, +// or expires_at in the past). The single-use guarantee comes from the +// WHERE clause: two simultaneous clicks resolve to exactly one row update. +func ApprovePromoteApproval(ctx context.Context, db *sql.DB, id uuid.UUID) (bool, error) { + res, err := db.ExecContext(ctx, ` + UPDATE promote_approvals + SET status = 'approved', approved_at = now() + WHERE id = $1 AND status = 'pending' AND expires_at > now() + `, id) + if err != nil { + return false, fmt.Errorf("models.ApprovePromoteApproval: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("models.ApprovePromoteApproval rows: %w", err) + } + return n == 1, nil +} + +// MarkPromoteApprovalExpired flips a row's status to 'expired' when its +// expires_at is in the past and it's still pending. Best-effort — used by +// the GET /approve handler to make the second click on an old link +// surface a "link expired" message instead of "link invalid". The first +// click that touches the row after expiry does the flip; further reads +// see status='expired' and can branch on that. +func MarkPromoteApprovalExpired(ctx context.Context, db *sql.DB, id uuid.UUID) error { + _, err := db.ExecContext(ctx, ` + UPDATE promote_approvals + SET status = 'expired' + WHERE id = $1 AND status = 'pending' AND expires_at <= now() + `, id) + if err != nil { + return fmt.Errorf("models.MarkPromoteApprovalExpired: %w", err) + } + return nil +} + +// RejectPromoteApproval flips a pending row to rejected. Admin-only — +// the handler enforces ADMIN_EMAILS gating before calling this. Returns +// (true, nil) on success, (false, nil) when the row is no longer +// pending (already approved, expired, rejected). The atomic guard is +// the WHERE clause: admin clicks "reject" the same instant a user +// clicks the email link → exactly one of the two transitions wins. +func RejectPromoteApproval(ctx context.Context, db *sql.DB, id uuid.UUID) (bool, error) { + res, err := db.ExecContext(ctx, ` + UPDATE promote_approvals + SET status = 'rejected', rejected_at = now() + WHERE id = $1 AND status = 'pending' + `, id) + if err != nil { + return false, fmt.Errorf("models.RejectPromoteApproval: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("models.RejectPromoteApproval rows: %w", err) + } + return n == 1, nil +} + +// MarkPromoteApprovalExecuted flips an approved row to executed once the +// worker (out of scope for this PR) has actually run the cached promote. +// Provided here so the model layer owns every legal state transition — +// the worker repo will call this once its polling job lands. +func MarkPromoteApprovalExecuted(ctx context.Context, db *sql.DB, id uuid.UUID) (bool, error) { + res, err := db.ExecContext(ctx, ` + UPDATE promote_approvals + SET status = 'executed', executed_at = now() + WHERE id = $1 AND status = 'approved' AND executed_at IS NULL + `, id) + if err != nil { + return false, fmt.Errorf("models.MarkPromoteApprovalExecuted: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("models.MarkPromoteApprovalExecuted rows: %w", err) + } + return n == 1, nil +} + +// ListPromoteApprovalsParams is the filter shape for ListPromoteApprovals. +// status == "" means "all statuses" so callers don't have to specialise +// the list call for the "everything" view. Limit is clamped server-side. +type ListPromoteApprovalsParams struct { + Status string + Limit int +} + +// promoteApprovalsMaxLimit caps the result set so an unbounded list +// request can't sweep the table. Mirrors auditMaxLimit (audit_log.go). +const promoteApprovalsMaxLimit = 200 + +// ListPromoteApprovals returns the most recent rows matching the filter, +// newest first. Used by the admin dashboard's "what's awaiting approval" +// view. Filters by status when set, returns everything otherwise. +func ListPromoteApprovals(ctx context.Context, db *sql.DB, p ListPromoteApprovalsParams) ([]*PromoteApproval, error) { + limit := p.Limit + if limit <= 0 { + limit = 50 + } + if limit > promoteApprovalsMaxLimit { + limit = promoteApprovalsMaxLimit + } + + var rows *sql.Rows + var err error + if p.Status == "" { + rows, err = db.QueryContext(ctx, ` + SELECT id, token, team_id, requested_by_email, promote_kind, promote_payload, + from_env, to_env, status, created_at, expires_at, approved_at, executed_at, rejected_at + FROM promote_approvals + ORDER BY created_at DESC + LIMIT $1 + `, limit) + } else { + rows, err = db.QueryContext(ctx, ` + SELECT id, token, team_id, requested_by_email, promote_kind, promote_payload, + from_env, to_env, status, created_at, expires_at, approved_at, executed_at, rejected_at + FROM promote_approvals + WHERE status = $1 + ORDER BY created_at DESC + LIMIT $2 + `, p.Status, limit) + } + if err != nil { + return nil, fmt.Errorf("models.ListPromoteApprovals: %w", err) + } + defer rows.Close() + + out := make([]*PromoteApproval, 0) + for rows.Next() { + row := &PromoteApproval{} + if err := rows.Scan( + &row.ID, &row.Token, &row.TeamID, &row.RequestedByEmail, &row.PromoteKind, &row.PromotePayload, + &row.FromEnv, &row.ToEnv, &row.Status, &row.CreatedAt, &row.ExpiresAt, + &row.ApprovedAt, &row.ExecutedAt, &row.RejectedAt, + ); err != nil { + return nil, fmt.Errorf("models.ListPromoteApprovals scan: %w", err) + } + out = append(out, row) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("models.ListPromoteApprovals rows: %w", err) + } + return out, nil +} diff --git a/internal/router/router.go b/internal/router/router.go index 3de189a4..02c294ad 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -213,6 +213,13 @@ func New(cfg *config.Config, db *sql.DB, rdb *redis.Client, geoDbs *middleware.G app.Get("/claim/preview", onboardH.ClaimPreview) app.Post("/claim", onboardH.Claim) + // Email-link approval workflow for non-dev env promotions (migration 026). + // Public, token-IS-the-credential route — never sits inside the /api/v1 + // RequireAuth group. Rate-limited per-IP inside the handler (defends the + // token space against brute-force). + promoteApprovalH := handlers.NewPromoteApprovalHandler(db, rdb) + app.Get("/approve/:token", promoteApprovalH.Approve) + // Provisioning — Phase 2+ (gated by IsServiceEnabled in each handler) // OptionalAuth is registered per-route rather than via app.Group("/", ...) to avoid // accidentally applying it globally to all routes (Fiber's "/" group prefix matches everything). @@ -487,6 +494,14 @@ func New(cfg *config.Config, db *sql.DB, rdb *redis.Client, geoDbs *middleware.G // table shape and self-report contract. deploysAuditH := handlers.NewDeploysAuditHandler(db) adminGroup.Get("/deploys", deploysAuditH.List) + + // Promote-approval admin surface (migration 026). Read-only list + // + a reject endpoint that flips a pending row to rejected. The + // public GET /approve/:token route is wired ABOVE outside the + // admin gate — clicking the email link does NOT require an + // admin session (the token IS the credential there). + adminGroup.Get("/promotions", promoteApprovalH.List) + adminGroup.Post("/promotions/:id/reject", promoteApprovalH.Reject) } // Quota-wall nudge endpoint — Track U1. Returns the most recent diff --git a/internal/testhelpers/testhelpers.go b/internal/testhelpers/testhelpers.go index 94d3ff7a..491d79c8 100644 --- a/internal/testhelpers/testhelpers.go +++ b/internal/testhelpers/testhelpers.go @@ -296,6 +296,27 @@ func runMigrations(t *testing.T, db *sql.DB) { )`, `CREATE UNIQUE INDEX IF NOT EXISTS uq_deploys_audit_identity ON deploys_audit(service, commit_id, image_digest)`, `CREATE INDEX IF NOT EXISTS idx_deploys_audit_service_time ON deploys_audit(service, applied_at DESC)`, + // 026_promote_approvals — email-link approval workflow for non-dev + // env promotions. Mirrored here so handler tests bringing up a fresh + // test DB get the table without running the SQL migration separately. + `CREATE TABLE IF NOT EXISTS promote_approvals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token TEXT UNIQUE NOT NULL, + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, + requested_by_email TEXT NOT NULL, + promote_kind TEXT NOT NULL, + promote_payload JSONB NOT NULL, + from_env TEXT NOT NULL, + to_env TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + approved_at TIMESTAMPTZ, + executed_at TIMESTAMPTZ, + rejected_at TIMESTAMPTZ + )`, + `CREATE INDEX IF NOT EXISTS idx_promote_approvals_token ON promote_approvals(token) WHERE status = 'pending'`, + `CREATE INDEX IF NOT EXISTS idx_promote_approvals_pending_exec ON promote_approvals(status) WHERE status = 'approved' AND executed_at IS NULL`, } for _, s := range stmts {