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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions internal/db/migrations/026_promote_approvals.sql
Original file line number Diff line number Diff line change
@@ -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/<token> link.
-- 3. Operator clicks → GET /approve/<token> 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;
33 changes: 33 additions & 0 deletions internal/handlers/agent_action.go
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,38 @@ func newAgentActionAdminPromoIssued(teamID, code string) string {
)
}

<<<<<<< HEAD
// ─────────────────────────────────────────────────────────────────────────────
// 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."
=======
// AgentActionReadOnlySession is returned on every 403 emitted by the
// RequireWritable middleware when a JWT carries `read_only:true` — i.e. the
// admin minted an impersonation token to view-as-customer and the agent
Expand All @@ -317,3 +349,4 @@ func newAgentActionAdminPromoIssued(teamID, code string) string {
// there is no "downgrade to writable" path. The remedy is to use the
// admin's own session token, which never carries this flag.
const AgentActionReadOnlySession = "Tell the user this is a read-only impersonated session. Mutations are disabled. Switch back to your real account at https://instanode.dev/app to make changes."
>>>>>>> origin/master
6 changes: 3 additions & 3 deletions internal/handlers/agent_action_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,17 @@ func agentActionContractCases() map[string]string {
"AgentActionPromotionInvalid": AgentActionPromotionInvalid,
"AgentActionPromotionAlreadyUsed": AgentActionPromotionAlreadyUsed,
"AgentActionPromotionExpired": AgentActionPromotionExpired,
<<<<<<< HEAD
"AgentActionPromoteTokenExpired": AgentActionPromoteTokenExpired,
"AgentActionReadOnlySession": AgentActionReadOnlySession,
=======
"AgentActionNotifyWebhookInvalid": AgentActionNotifyWebhookInvalid,
"AgentActionPauseRequiresPro": AgentActionPauseRequiresPro,
"AgentActionResourceAlreadyPaused": AgentActionResourceAlreadyPaused,
"AgentActionResourceNotPaused": AgentActionResourceNotPaused,
>>>>>>> origin/master

// 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"),
Expand Down Expand Up @@ -106,6 +105,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",
"Switch", "switch", // read-only impersonation
"check ", "Check ", // bindings cross-team / not-found
Expand Down
47 changes: 33 additions & 14 deletions internal/handlers/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<token> 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": {
Expand All @@ -196,23 +196,39 @@ 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." }
}
}
}
}
},
"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/<id>?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)" }
}
}
},
Expand Down Expand Up @@ -745,7 +761,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/<token> 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": {
Expand All @@ -756,21 +772,24 @@ 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)." }
}
}
}
}
},
"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" }
}
}
Expand Down
Loading