Files
molecule-cli/internal/cmd/http.go
sdk-dev 442d1ebaf4
CI / Test / test (pull_request) Waiting to run
Release Go binaries / release (pull_request) Blocked by required conditions
Release Go binaries / test (pull_request) Waiting to run
fix(cli): repoint org create/list to CP-admin bearer; fail-fast get/export
Review fix for #13. The CP org verbs targeted /api/v1/orgs*, which is gated by
RequireSession() (WorkOS cookie-only) — a bearer-token CLI can't authenticate
and these 401 in prod; the tenant Org API Key has no standing on the CP at all.

- org create/list now target the CP ADMIN routes (POST/GET /api/v1/admin/orgs,
  AdminGate bearer), authenticated with a DISTINCT credential MOLECULE_CP_ADMIN_TOKEN
  (never the tenant MOLECULE_API_KEY). create now requires --owner-user-id, per
  controlplane adminCreateOrgRequest{slug,name,owner_user_id}. ListOrgs decodes
  the {limit,offset,orgs[]} admin-summary envelope. Two-credential split is
  documented in `org`/`org create` help text; the org key is never sent to the CP.
- org get/export have NO AdminGate-reachable route on the CP (session-only), so
  they fail fast with a clear "session-only, use the dashboard" error instead of
  shipping verbs that 401.
- cpAdminClient() fails fast with guidance when MOLECULE_CP_ADMIN_TOKEN is unset
  (wrong-credential path), rather than silently sending the org key to the CP.
- Wire Execute() through handleErr so SilenceErrors'd exitError messages actually
  print (they were previously swallowed by main's bare os.Exit(1)) — required for
  the fail-fast guidance to reach the user.
- Optional cleanup: extract resolveBillingMode()/budgetLimitsFromFlags() so prod
  and tests share one definition.
- Tests: client + cmd assert org verbs hit /api/v1/admin/orgs with the CP-admin
  bearer (no org-id header, no org-key leak), the missing-owner and
  missing-admin-token fail-fast paths, get/export fail-fast, and an e2e CLI test
  that `org list` without the admin token exits non-zero naming MOLECULE_CP_ADMIN_TOKEN.

Budget shape (budget_limits) left unchanged — confirmed correct; the OpenAPI
spec is the stale one (fixed separately on #2056).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 22:35:22 -07:00

86 lines
2.8 KiB
Go

package cmd
import (
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
// authToken returns the management API key the CLI authenticates with.
// This is the org-scoped tenant-admin key ("Org API Key" in the dashboard),
// presented to the tenant host as a bearer token. Read from MOLECULE_API_KEY.
func authToken() string {
return os.Getenv("MOLECULE_API_KEY")
}
// orgID returns the org id used to satisfy the tenant's X-Molecule-Org-Id
// routing gate (TenantGuard). Read from MOLECULE_ORG_ID.
func orgID() string {
return os.Getenv("MOLECULE_ORG_ID")
}
// cpAdminToken returns the control-plane ADMIN bearer used for org-lifecycle
// verbs that hit the CP admin surface (POST/GET /api/v1/admin/orgs).
//
// This is a DISTINCT credential from MOLECULE_API_KEY (the tenant Org API
// Key). The CP's customer-facing /api/v1/orgs* routes are gated by a WorkOS
// browser session (RequireSession), which a bearer-token CLI cannot satisfy —
// and the Org API Key has no standing on the CP at all. The admin routes are
// gated by AdminGate, which accepts a server-to-server bearer. We deliberately
// keep this in its own env var so the tenant org key is NEVER sent to the CP.
// Read from MOLECULE_CP_ADMIN_TOKEN.
func cpAdminToken() string {
return os.Getenv("MOLECULE_CP_ADMIN_TOKEN")
}
// setAuthHeaders attaches the management credentials to req.
//
// Before this existed, management calls (workspace create/delete, secrets,
// tokens, …) reached a hardened tenant with NO Authorization header and were
// rejected with 401. The Org API Key is a tenant credential presented as
// `Authorization: Bearer <key>`; the tenant's TenantGuard additionally
// requires `X-Molecule-Org-Id: <orgId>` to route the request to the right
// org host. We set the org header only when MOLECULE_ORG_ID is configured so
// single-tenant / dev hosts (which don't gate on it) keep working.
func setAuthHeaders(req *http.Request) {
if tok := authToken(); tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
if oid := orgID(); oid != "" {
req.Header.Set("X-Molecule-Org-Id", oid)
}
}
// runHTTP does a raw HTTP call with management authentication attached.
func runHTTP(method, url string, body []byte) ([]byte, error) {
var reader io.Reader
if body != nil {
reader = strings.NewReader(string(body))
}
req, err := http.NewRequest(method, url, reader)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
setAuthHeaders(req)
resp, err := httpClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("HTTP %d — %s", resp.StatusCode, string(b))
}
return b, nil
}
func httpClient() *http.Client {
return &http.Client{Timeout: 30 * time.Second}
}