Files
molecule-cli/internal/cmd/org.go
sdk-dev 0ec3db81e6
CI / Test / test (pull_request) Successful in 2m3s
Release Go binaries / test (pull_request) Successful in 2m9s
Release Go binaries / release (pull_request) Waiting to run
fix(cli): address CR2 review on #13 — path-escaping, config binding, CP-admin targeting
CR2 review findings on PR #13 (branch feat/management-cli-verbs):

1. [HIGH] PathEscape user-controlled path segments. platform.go built paths
   via fmt.Sprintf on raw caller IDs (GetWorkspace/DeleteWorkspace/
   RestartWorkspace/ListWorkspaceAgents/GetAgent/GetPeers/GetDelegations) and
   the agent-send / workspace-delegate runHTTP call sites concatenated raw IDs.
   An ID with '/', '?' or '#' could alter the endpoint or leak into the query.
   Wrapped every caller-supplied segment in url.PathEscape (management.go
   already did this). DeleteWorkspace's ?confirm=true is now injection-safe.
   Severity note: this runs under the user's own management creds, so it is
   primarily robustness/correctness rather than a privilege-escalation hole.

2. [MED] Config not bound to globals. viper read the config file but the
   flag-backed apiURL/outputFormat globals were never populated from it, so
   `molecule config set api_url` did not affect newClient()/cpURL(). Added
   applyConfigDefaults(): config file is adopted only when no env override and
   the global is still at its built-in default, so precedence stays
   flag > env > config file > default.

3. [MED] MintWorkspaceToken sent a nil body → JSON `null`. Now sends an empty
   object (struct{}{}) → `{}`, matching sibling tooling and avoiding rejection
   by a handler that decodes into a struct/map.

4. [MED] cpURL defaulted to apiURL (tenant host), so an unset MOLECULE_CP_URL
   would send the privileged CP-admin bearer to a tenant host. cpURL() no
   longer falls back to apiURL; cpAdminClient() now requires an explicit
   MOLECULE_CP_URL and fails fast otherwise. Updated org.go help text.

5. [LOW] config set now os.MkdirAll's the config dir before WriteConfig/
   SafeWriteConfig, which otherwise fail on a fresh machine where ~/.config
   doesn't exist yet.

Tests: added path-segment escaping coverage (platform + delete), MintWorkspaceToken
body={}, applyConfigDefaults precedence, config-set mkdir, and CP-admin credential
targeting; retargeted TestCPURLFallback → TestCPURLNoTenantFallback.
go build/vet/test all green; gofmt clean on edited files.

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

323 lines
12 KiB
Go

// Package cmd implements the CLI command tree.
package cmd
import (
"fmt"
"os"
"text/tabwriter"
"github.com/spf13/cobra"
"go.moleculesai.app/cli/internal/client"
)
// ---------------------------------------------------------------------------
// Org command group.
//
// TWO credentials, two surfaces — do not conflate them:
//
// - Org-LIFECYCLE verbs (create / list) talk to the CONTROL-PLANE ADMIN API
// (MOLECULE_CP_URL — REQUIRED, no tenant-host fallback) and need a CP-ADMIN
// bearer in MOLECULE_CP_ADMIN_TOKEN. MOLECULE_CP_URL is intentionally not
// defaulted to --api-url so the privileged CP-admin bearer is never sent to
// a tenant host. The customer-facing /api/v1/orgs* routes are
// WorkOS-session-gated (RequireSession) and cannot be reached by any
// bearer-token CLI; the admin routes (/api/v1/admin/orgs, AdminGate) can.
// The tenant Org API Key is NEVER sent to the control plane.
//
// - org get / org export have NO bearer-reachable CP route (session-only),
// so they fail fast with guidance rather than 401'ing.
//
// - Tenant-scoped sub-verbs (create --template, token *, allowlist) talk to
// the TENANT host (api-url) with the Org API Key (MOLECULE_API_KEY +
// MOLECULE_ORG_ID), per PLATFORM-MANAGEMENT-API.md §1/§3.
// ---------------------------------------------------------------------------
var orgCmd = &cobra.Command{
Use: "org",
Short: "Manage organizations (control plane) and org-scoped tenant resources",
Long: `Manage organizations.
Org-lifecycle verbs (create, list) use the CONTROL-PLANE ADMIN API and require
a CP-admin bearer token — a credential DISTINCT from the tenant Org API Key:
MOLECULE_CP_ADMIN_TOKEN CP admin bearer (org create/list)
MOLECULE_CP_URL CP base URL (required; e.g. https://api.moleculesai.app —
NOT defaulted to --api-url, to keep the CP-admin
bearer off tenant hosts)
The tenant Org API Key (MOLECULE_API_KEY / MOLECULE_ORG_ID) is used ONLY for
the tenant-scoped sub-verbs (create --template, token *, allowlist) and is
never sent to the control plane.
org get / org export are not available to token-authenticated callers (the
control plane gates them behind a WorkOS browser session); use the dashboard.`,
}
func init() {
orgCmd.AddCommand(
orgListCmd, orgGetCmd, orgCreateCmd, orgExportCmd,
orgTokenCmd, orgAllowlistCmd,
)
orgTokenCmd.AddCommand(orgTokenListCmd, orgTokenCreateCmd, orgTokenRevokeCmd)
}
// ===========================================================================
// molecule org list
// ===========================================================================
var orgListCmd = &cobra.Command{
Use: "list",
Short: "List organizations (control plane)",
RunE: runOrgList,
}
func runOrgList(_ *cobra.Command, _ []string) error {
cp, err := cpAdminClient()
if err != nil {
return err
}
orgs, err := cp.ListOrgs()
if err != nil {
return fmt.Errorf("org list: %w", err)
}
if outputFormat == "json" {
return printJSON(orgs)
}
if outputFormat == "yaml" {
return printYAML(orgs)
}
if len(orgs) == 0 {
fmt.Println("No organizations found.")
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "SLUG\tNAME\tPLAN\tINSTANCE\tMEMBERS")
for _, o := range orgs {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\n", o.Slug, o.Name, o.Plan, o.InstanceStatus, o.MemberCount)
}
return w.Flush()
}
// ===========================================================================
// molecule org get <slug>
// ===========================================================================
var orgGetCmd = &cobra.Command{
Use: "get <slug>",
Short: "Show a single org (UNAVAILABLE via token auth — session-only on the CP)",
Args: cobra.MaximumNArgs(1),
RunE: runOrgGet,
}
func runOrgGet(_ *cobra.Command, _ []string) error {
// The control plane exposes GET /api/v1/orgs/:slug only behind a WorkOS
// browser session (RequireSession); there is no AdminGate-reachable
// admin equivalent. A token-authenticated CLI therefore cannot serve
// this verb — fail fast with guidance instead of shipping a guaranteed
// 401. `org list` (admin-bearer) covers the common "what orgs exist".
return &exitError{code: 2, msg: "org get is not available to token-authenticated callers: the control plane gates GET /api/v1/orgs/:slug behind a WorkOS browser session, and no CP-admin bearer route exists for it. Use the dashboard, or `molecule org list` (needs MOLECULE_CP_ADMIN_TOKEN) for a fleet overview."}
}
// ===========================================================================
// molecule org create
// ===========================================================================
var orgCreateFlags struct {
slug string
name string
ownerUserID string
template string
mode string
}
var orgCreateCmd = &cobra.Command{
Use: "create --slug <slug> --name <name> --owner-user-id <id> | --template <dir>",
Short: "Create an org (control-plane admin) or provision workspaces from an org template (tenant)",
Long: `Create an organization on the control plane (admin surface):
MOLECULE_CP_ADMIN_TOKEN=<cp-admin-bearer> \
molecule org create --slug acme --name "Acme Inc" --owner-user-id user_123
This targets POST /api/v1/admin/orgs (AdminGate) — NOT the WorkOS-session-gated
POST /api/v1/orgs, which a bearer-token CLI cannot reach. It requires a CP-admin
bearer in MOLECULE_CP_ADMIN_TOKEN (distinct from the tenant Org API Key
MOLECULE_API_KEY) and an explicit --owner-user-id (the server-to-server path has
no implicit session to own the new org).
Or provision workspaces into the current tenant from an org template
directory (POST /org/import) — this uses the tenant Org API Key:
molecule org create --template my-org-template [--mode merge|reconcile]`,
RunE: runOrgCreate,
}
func init() {
f := orgCreateCmd.Flags()
f.StringVar(&orgCreateFlags.slug, "slug", "", "Org slug (control-plane admin create)")
f.StringVar(&orgCreateFlags.name, "name", "", "Org display name (control-plane admin create)")
f.StringVar(&orgCreateFlags.ownerUserID, "owner-user-id", "", "Owner user id for the new org (required for control-plane admin create)")
f.StringVar(&orgCreateFlags.template, "template", "", "Org template directory (tenant org-from-template)")
f.StringVar(&orgCreateFlags.mode, "mode", "", "Template import mode: merge (default) | reconcile")
}
func runOrgCreate(_ *cobra.Command, _ []string) error {
// Template path → tenant POST /org/import.
if orgCreateFlags.template != "" {
raw, err := newClient().CreateOrgFromTemplate(client.ImportOrgRequest{
Dir: orgCreateFlags.template,
Mode: orgCreateFlags.mode,
})
if err != nil {
return fmt.Errorf("org create --template: %w", err)
}
return printRaw(raw)
}
// Control-plane admin org create (POST /api/v1/admin/orgs).
if orgCreateFlags.slug == "" || orgCreateFlags.name == "" {
return &exitError{code: 2, msg: "org create: provide --slug, --name and --owner-user-id (CP admin create), or --template (tenant org-from-template)"}
}
if orgCreateFlags.ownerUserID == "" {
return &exitError{code: 2, msg: "org create: --owner-user-id is required for control-plane create (the admin route POST /api/v1/admin/orgs has no implicit session to own the org)"}
}
cp, err := cpAdminClient()
if err != nil {
return err
}
o, err := cp.CreateOrg(client.CreateOrgRequest{
Slug: orgCreateFlags.slug,
Name: orgCreateFlags.name,
OwnerUserID: orgCreateFlags.ownerUserID,
})
if err != nil {
return fmt.Errorf("org create: %w", err)
}
if outputFormat == "json" {
return printJSON(o)
}
if outputFormat == "yaml" {
return printYAML(o)
}
fmt.Printf("Organization created: %s (%s)\n", o.Name, o.Slug)
return nil
}
// ===========================================================================
// molecule org export <slug>
// ===========================================================================
var orgExportCmd = &cobra.Command{
Use: "export <slug>",
Short: "Export an org (UNAVAILABLE via token auth — session-only on the CP)",
Args: cobra.MaximumNArgs(1),
RunE: runOrgExport,
}
func runOrgExport(_ *cobra.Command, _ []string) error {
// Same story as org get: GET /api/v1/orgs/:slug/export is session-only on
// the control plane with no AdminGate-reachable equivalent, so a
// token-authenticated CLI cannot serve it. Fail fast.
return &exitError{code: 2, msg: "org export is not available to token-authenticated callers: the control plane gates GET /api/v1/orgs/:slug/export behind a WorkOS browser session, and no CP-admin bearer route exists for it. Use the dashboard."}
}
// ===========================================================================
// molecule org token {list,create,revoke}
// ===========================================================================
var orgTokenCmd = &cobra.Command{
Use: "token",
Short: "Manage Org API Keys (tenant org-admin tokens)",
}
var orgTokenListCmd = &cobra.Command{
Use: "list",
Short: "List Org API Keys",
RunE: runOrgTokenList,
}
func runOrgTokenList(_ *cobra.Command, _ []string) error {
toks, err := newClient().ListOrgTokens()
if err != nil {
return fmt.Errorf("org token list: %w", err)
}
if outputFormat == "json" {
return printJSON(toks)
}
if outputFormat == "yaml" {
return printYAML(toks)
}
if len(toks) == 0 {
fmt.Println("No org tokens found.")
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "ID\tPREFIX\tNAME\tCREATED AT")
for _, t := range toks {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", t.ID, t.Prefix, t.Name, t.CreatedAt)
}
return w.Flush()
}
var orgTokenCreateFlags struct{ name string }
var orgTokenCreateCmd = &cobra.Command{
Use: "create [--name <name>]",
Short: "Mint a new Org API Key (plaintext shown once)",
RunE: runOrgTokenCreate,
}
func init() {
orgTokenCreateCmd.Flags().StringVar(&orgTokenCreateFlags.name, "name", "", "Human-readable token name")
}
func runOrgTokenCreate(_ *cobra.Command, _ []string) error {
resp, err := newClient().CreateOrgToken(orgTokenCreateFlags.name)
if err != nil {
return fmt.Errorf("org token create: %w", err)
}
if outputFormat == "json" {
return printJSON(resp)
}
if outputFormat == "yaml" {
return printYAML(resp)
}
fmt.Printf("Org API Key created: %s (prefix %s)\n", resp.ID, resp.Prefix)
fmt.Printf("auth_token: %s\n", resp.Token)
if resp.Warning != "" {
fmt.Printf("WARNING: %s\n", resp.Warning)
}
return nil
}
var orgTokenRevokeCmd = &cobra.Command{
Use: "revoke <token-id>",
Short: "Revoke an Org API Key by id",
Args: cobra.ExactArgs(1),
RunE: runOrgTokenRevoke,
}
func runOrgTokenRevoke(_ *cobra.Command, args []string) error {
if err := newClient().RevokeOrgToken(args[0]); err != nil {
return fmt.Errorf("org token revoke: %w", err)
}
fmt.Printf("Org token %q revoked.\n", args[0])
return nil
}
// ===========================================================================
// molecule org allowlist [<org-id>]
// ===========================================================================
var orgAllowlistCmd = &cobra.Command{
Use: "allowlist [org-id]",
Short: "Show the org plugin allowlist",
Args: cobra.MaximumNArgs(1),
RunE: runOrgAllowlist,
}
func runOrgAllowlist(_ *cobra.Command, args []string) error {
id := orgID()
if len(args) == 1 {
id = args[0]
}
if id == "" {
return &exitError{code: 2, msg: "org allowlist: provide an org id argument or set MOLECULE_ORG_ID"}
}
raw, err := newClient().GetAllowlist(id)
if err != nil {
return fmt.Errorf("org allowlist: %w", err)
}
return printRaw(raw)
}