0ec3db81e6
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>
323 lines
12 KiB
Go
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)
|
|
}
|