442d1ebaf4
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>
86 lines
2.8 KiB
Go
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}
|
|
}
|