diff --git a/cmd/megaport/common_wasm.go b/cmd/megaport/common_wasm.go index 900020c9..2f502b3d 100644 --- a/cmd/megaport/common_wasm.go +++ b/cmd/megaport/common_wasm.go @@ -84,6 +84,7 @@ func InitializeCommon() { rootCmd.PersistentFlags().IntVar(&utils.MaxRetries, "max-retries", 3, "Maximum number of retries for transient API failures") rootCmd.PersistentFlags().BoolVar(&noHeader, "no-header", false, "Suppress table and CSV column headers (useful for scripting)") rootCmd.PersistentFlags().BoolVar(&noPager, "no-pager", false, "Disable pager for long table output (no-op in browser version)") + rootCmd.PersistentFlags().StringVar(&utils.ManagedAccountUID, "on-behalf-of", "", "Act on behalf of a managed account: company UID sent as the X-Call-Context header on every authenticated request (falls back to MEGAPORT_MANAGED_ACCOUNT_UID)") rootCmd.MarkFlagsMutuallyExclusive("quiet", "verbose") rootCmd.SuggestionsMinimumDistance = 2 diff --git a/cmd/megaport/megaport_common.go b/cmd/megaport/megaport_common.go index 6e84dd49..7b7542c7 100644 --- a/cmd/megaport/megaport_common.go +++ b/cmd/megaport/megaport_common.go @@ -101,6 +101,7 @@ func InitializeCommon() { rootCmd.PersistentFlags().BoolVar(&utils.LogHTTP, "log-http", false, "Log raw HTTP requests/responses to stderr for debugging (may include sensitive data such as auth tokens)") rootCmd.PersistentFlags().StringVar(&utils.BaseURL, "base-url", "", "Override the API base URL (e.g. http://localhost:8080); takes precedence over --env and any profile environment") rootCmd.PersistentFlags().StringVar(&utils.TokenURL, "token-url", "", "Override the OAuth token endpoint (typically used with --base-url when auth is served from a non-standard host)") + rootCmd.PersistentFlags().StringVar(&utils.ManagedAccountUID, "on-behalf-of", "", "Act on behalf of a managed account: company UID sent as the X-Call-Context header on every authenticated request (falls back to MEGAPORT_MANAGED_ACCOUNT_UID)") rootCmd.PersistentFlags().BoolVar(&noHeader, "no-header", false, "Suppress table and CSV column headers (useful for scripting)") rootCmd.PersistentFlags().BoolVar(&noPager, "no-pager", false, "Disable pager for long table output") rootCmd.MarkFlagsMutuallyExclusive("quiet", "verbose") diff --git a/docs/megaport-cli.md b/docs/megaport-cli.md index 3c404bf4..0a3d2583 100644 --- a/docs/megaport-cli.md +++ b/docs/megaport-cli.md @@ -56,6 +56,7 @@ megaport-cli [flags] | `--no-header` | | `false` | Suppress table and CSV column headers (useful for scripting) | false | | `--no-pager` | | `false` | Disable pager for long table output | false | | `--no-retry` | | `false` | Disable automatic retry on transient API failures | false | +| `--on-behalf-of` | | | Act on behalf of a managed account: company UID sent as the X-Call-Context header on every authenticated request (falls back to MEGAPORT_MANAGED_ACCOUNT_UID) | false | | `--output` | `-o` | `table` | Output format (table, json, csv, xml, go-template; requires --template when using go-template) | false | | `--profile` | | | Use a specific config profile for this command | false | | `--query` | | | JMESPath query to filter JSON output (requires --output json) | false | diff --git a/frontend-integration/types/megaport-wasm.d.ts b/frontend-integration/types/megaport-wasm.d.ts index 89041d63..6f48c006 100644 --- a/frontend-integration/types/megaport-wasm.d.ts +++ b/frontend-integration/types/megaport-wasm.d.ts @@ -138,12 +138,15 @@ export interface MegaportWASM { * @param accessKey - Megaport API access key * @param secretKey - Megaport API secret key * @param environment - Environment (production, staging, development) + * @param managedAccountUID - Optional managed account company UID to act on + * behalf of; sent as the X-Call-Context header on authenticated requests * @returns Result object with success status */ setAuthCredentials( accessKey: string, secretKey: string, - environment: string + environment: string, + managedAccountUID?: string ): { success: boolean; error?: string }; /** @@ -332,7 +335,8 @@ declare global { setAuthCredentials?: ( accessKey: string, secretKey: string, - environment: string + environment: string, + managedAccountUID?: string ) => { success: boolean; error?: string }; setAuthToken?: ( token: string, diff --git a/internal/commands/config/config_shared.go b/internal/commands/config/config_shared.go index 367aa87a..888d4eb7 100644 --- a/internal/commands/config/config_shared.go +++ b/internal/commands/config/config_shared.go @@ -1,9 +1,12 @@ package config import ( + "os" "strings" megaport "github.com/megaport/megaportgo" + + "github.com/megaport/megaport-cli/internal/utils" ) // ConfigFile represents the configuration file structure @@ -50,6 +53,17 @@ func normalizeEnvironment(env string) string { } } +// resolveManagedAccountUID returns the managed account UID to act on behalf of: +// the --on-behalf-of flag if set, otherwise the MEGAPORT_MANAGED_ACCOUNT_UID env +// var. Values are trimmed, so a whitespace-only value counts as unset and no +// X-Call-Context header is sent. +func resolveManagedAccountUID() string { + if uid := strings.TrimSpace(utils.ManagedAccountUID); uid != "" { + return uid + } + return strings.TrimSpace(os.Getenv("MEGAPORT_MANAGED_ACCOUNT_UID")) +} + // environmentOption returns the megaport.ClientOpt for the given environment string. func environmentOption(env string) megaport.ClientOpt { switch env { diff --git a/internal/commands/config/login.go b/internal/commands/config/login.go index e99627ed..f899091a 100644 --- a/internal/commands/config/login.go +++ b/internal/commands/config/login.go @@ -202,7 +202,12 @@ var loginFuncWithOutput = func(ctx context.Context, outputFormat string) (*megap httpClient := &http.Client{Timeout: 30 * time.Second} - baseOpts := []megaport.ClientOpt{megaport.WithCredentials(accessKey, secretKey), megaport.WithCustomHeaders(cliHeaders)} + managedAccountUID := resolveManagedAccountUID() + baseOpts := []megaport.ClientOpt{ + megaport.WithCredentials(accessKey, secretKey), + megaport.WithCustomHeaders(cliHeaders), + megaport.WithCallContext(managedAccountUID), + } if utils.BaseURL != "" { warnIfInsecureBaseURL(utils.BaseURL) baseOpts = append(baseOpts, megaport.WithBaseURL(utils.BaseURL)) @@ -236,6 +241,9 @@ var loginFuncWithOutput = func(ctx context.Context, outputFormat string) (*megap target = strings.ToUpper(env[:1]) + env[1:] } spinner.StopWithSuccess(fmt.Sprintf("Successfully logged in to Megaport %s", target)) + if managedAccountUID != "" { + fmt.Fprintf(os.Stderr, "Acting on behalf of managed account %s\n", managedAccountUID) + } } return megaportClient, nil diff --git a/internal/commands/config/login_test.go b/internal/commands/config/login_test.go index 48e58d1f..00ce9e15 100644 --- a/internal/commands/config/login_test.go +++ b/internal/commands/config/login_test.go @@ -855,6 +855,102 @@ func TestCLIHeadersSentOnRequests(t *testing.T) { assert.Equal(t, "cli", capturedHeaders.Get("x-app")) } +func TestOnBehalfOfCallContextHeader(t *testing.T) { + origBaseURL := utils.BaseURL + origTokenURL := utils.TokenURL + origEnv := utils.Env + origProfile := utils.ProfileOverride + origUID := utils.ManagedAccountUID + defer func() { + utils.BaseURL = origBaseURL + utils.TokenURL = origTokenURL + utils.Env = origEnv + utils.ProfileOverride = origProfile + utils.ManagedAccountUID = origUID + }() + + tempDir, err := os.MkdirTemp("", "megaport-call-context-test") + assert.NoError(t, err) + t.Cleanup(func() { os.RemoveAll(tempDir) }) + + tests := []struct { + name string + flagUID string + envUID string + wantHeader string // "" means the header must be absent + }{ + {name: "flag sets header", flagUID: "flag-uid-123", wantHeader: "flag-uid-123"}, + {name: "env var used when no flag", envUID: "env-uid-456", wantHeader: "env-uid-456"}, + {name: "flag wins over env var", flagUID: "flag-uid-123", envUID: "env-uid-456", wantHeader: "flag-uid-123"}, + {name: "no flag and no env sends no header", wantHeader: ""}, + {name: "flag is trimmed", flagUID: " flag-uid-123\n", wantHeader: "flag-uid-123"}, + {name: "whitespace-only flag sends no header", flagUID: " ", wantHeader: ""}, + {name: "whitespace-only flag falls back to env", flagUID: " ", envUID: "env-uid-456", wantHeader: "env-uid-456"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + headersCh := make(chan http.Header, 1) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/oauth2/token" { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprint(w, `{"access_token":"test-token","token_type":"Bearer","expires_in":3600}`) + return + } + select { + case headersCh <- r.Header.Clone(): + default: + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"message":"ok"}`)) + })) + defer ts.Close() + + t.Setenv("MEGAPORT_CONFIG_DIR", tempDir) + t.Setenv("MEGAPORT_ACCESS_KEY", "test-key") + t.Setenv("MEGAPORT_SECRET_KEY", "test-secret") + t.Setenv("MEGAPORT_MANAGED_ACCOUNT_UID", tt.envUID) + + utils.BaseURL = ts.URL + utils.TokenURL = ts.URL + "/oauth2/token" + utils.Env = "" + utils.ProfileOverride = "" + utils.ManagedAccountUID = tt.flagUID + + origStderr := os.Stderr + devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0) + assert.NoError(t, err) + os.Stderr = devNull + t.Cleanup(func() { os.Stderr = origStderr; _ = devNull.Close() }) + + client, err := LoginWithOutput(context.Background(), "") + assert.NoError(t, err) + assert.NotNil(t, client) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "/", nil) + assert.NoError(t, err) + _, err = client.Do(context.Background(), req, nil) + assert.NoError(t, err) + + var captured http.Header + select { + case captured = <-headersCh: + case <-time.After(5 * time.Second): + t.Fatalf("timed out waiting for request headers from test server") + } + + // Assert on presence, not Get()=="": an empty Get can't distinguish + // an absent header from one set to the empty string. + values := captured.Values("X-Call-Context") + if tt.wantHeader == "" { + assert.Empty(t, values, "X-Call-Context must not be sent when no UID is resolved") + } else { + assert.Equal(t, []string{tt.wantHeader}, values) + } + }) + } +} + // stringerValue is a fmt.Stringer wrapped via slog.Any, exercising the // value-level redaction path for a value that isn't a plain slog string. type stringerValue string diff --git a/internal/commands/config/login_wasm.go b/internal/commands/config/login_wasm.go index b52c682a..bf4d201d 100644 --- a/internal/commands/config/login_wasm.go +++ b/internal/commands/config/login_wasm.go @@ -147,6 +147,10 @@ var loginFunc = func(ctx context.Context) (*megaport.Client, error) { // Create Megaport client with the external token (no OAuth flow needed!) clientOpts = append(clientOpts, megaport.WithCustomHeaders(cliHeaders)) + if uid := resolveManagedAccountUID(); uid != "" { + js.Global().Get("console").Call("log", "Acting on behalf of managed account: "+uid) + clientOpts = append(clientOpts, megaport.WithCallContext(uid)) + } megaportClient, err := megaport.New(httpClient, clientOpts...) if err != nil { js.Global().Get("console").Call("error", "Failed to create Megaport client: "+err.Error()) @@ -242,11 +246,16 @@ var loginFunc = func(ctx context.Context) (*megaport.Client, error) { // Create Megaport client with credentials js.Global().Get("console").Call("log", "Creating Megaport client...") - megaportClient, err := megaport.New(httpClient, + clientOpts := []megaport.ClientOpt{ megaport.WithCredentials(accessKey, secretKey), envOpt, megaport.WithCustomHeaders(cliHeaders), - ) + } + if uid := resolveManagedAccountUID(); uid != "" { + js.Global().Get("console").Call("log", "Acting on behalf of managed account: "+uid) + clientOpts = append(clientOpts, megaport.WithCallContext(uid)) + } + megaportClient, err := megaport.New(httpClient, clientOpts...) if err != nil { js.Global().Get("console").Call("error", "Failed to create Megaport client: "+err.Error()) js.Global().Get("console").Call("groupEnd") diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 6b65c13e..bd455bd6 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -75,6 +75,11 @@ var ( // is not one of the three standard Megaport auth hosts. Set via --token-url flag. TokenURL string + // ManagedAccountUID names a managed account company UID to act on behalf of, sent as the + // X-Call-Context header on authenticated requests. Set via --on-behalf-of flag; falls back + // to the MEGAPORT_MANAGED_ACCOUNT_UID env var when the flag is unset. + ManagedAccountUID string + ValidFormats = []string{FormatTable, FormatJSON, FormatCSV, FormatXML, FormatGoTemplate} ValidFormatsWASM = []string{FormatTable, FormatJSON, FormatCSV, FormatXML} ) diff --git a/internal/wasm/wasm.go b/internal/wasm/wasm.go index 847049cf..3c40a2ff 100644 --- a/internal/wasm/wasm.go +++ b/internal/wasm/wasm.go @@ -891,6 +891,17 @@ func setAuthCredentials(this js.Value, args []js.Value) interface{} { os.Setenv("MEGAPORT_SECRET_KEY", secretKey) os.Setenv("MEGAPORT_ENVIRONMENT", environment) + // Optional 4th arg: act on behalf of a managed account. Always reset it so a + // call without the arg clears a UID left over from a prior call. Only treat + // an actual string as a UID: a JS caller passing undefined/null would + // otherwise stringify to ""/"" and set a bogus header. + os.Unsetenv("MEGAPORT_MANAGED_ACCOUNT_UID") + if len(args) > 3 && args[3].Type() == js.TypeString { + if uid := args[3].String(); uid != "" { + os.Setenv("MEGAPORT_MANAGED_ACCOUNT_UID", uid) + } + } + // Clear any token left over from a prior setAuthToken call: loginFunc // checks the token path first, so a stale token would otherwise keep // silently overriding these credentials. @@ -917,6 +928,7 @@ func clearAuthCredentials(this js.Value, args []js.Value) interface{} { os.Unsetenv("MEGAPORT_ENVIRONMENT") os.Unsetenv("MEGAPORT_ACCESS_TOKEN") os.Unsetenv("MEGAPORT_API_URL") + os.Unsetenv("MEGAPORT_MANAGED_ACCOUNT_UID") js.Global().Delete("megaportCredentials") js.Global().Delete("megaportToken") @@ -1108,6 +1120,10 @@ func setAuthToken(this js.Value, args []js.Value) interface{} { // Clear any existing API key credentials to avoid confusion os.Setenv("MEGAPORT_ACCESS_KEY", "") os.Setenv("MEGAPORT_SECRET_KEY", "") + // Also clear any managed-account UID left by a prior setAuthCredentials call: + // a token session's on-behalf-of context must come from the --on-behalf-of + // flag, not a stale env var from a different auth session. + os.Unsetenv("MEGAPORT_MANAGED_ACCOUNT_UID") // Expose only metadata (not the raw token) in the JS global so the UI can // reflect the current session state without re-exposing the bearer token. diff --git a/internal/wasm/wasm_test.go b/internal/wasm/wasm_test.go index 23520c55..401f44aa 100644 --- a/internal/wasm/wasm_test.go +++ b/internal/wasm/wasm_test.go @@ -10,6 +10,7 @@ package wasm import ( "bytes" "math" + "os" "strings" "syscall/js" "testing" @@ -944,6 +945,42 @@ func TestSetAuthCredentials_ClearsStaleToken(t *testing.T) { js.Global().Get("clearAuthCredentials").Invoke() } +// TestSetAuthCredentials_ManagedAccountUID verifies the optional 4th arg sets +// the MEGAPORT_MANAGED_ACCOUNT_UID env var, that omitting it clears a UID left +// over from a prior call, and that clearAuthCredentials removes it. +func TestSetAuthCredentials_ManagedAccountUID(t *testing.T) { + RegisterJSFunctions() + t.Cleanup(func() { js.Global().Get("clearAuthCredentials").Invoke() }) + + js.Global().Get("clearAuthCredentials").Invoke() + + js.Global().Get("setAuthCredentials").Invoke("api-key", "api-secret", "staging", "managed-uid-789") + assert.Equal(t, "managed-uid-789", os.Getenv("MEGAPORT_MANAGED_ACCOUNT_UID")) + + // A subsequent call without the 4th arg must clear the stale UID. + js.Global().Get("setAuthCredentials").Invoke("api-key", "api-secret", "staging") + assert.Empty(t, os.Getenv("MEGAPORT_MANAGED_ACCOUNT_UID")) + + // A JS caller passing undefined/null as the 4th arg must not set a literal + // ""/"" UID; only an actual string counts. + js.Global().Get("setAuthCredentials").Invoke("api-key", "api-secret", "staging", "managed-uid-789") + js.Global().Get("setAuthCredentials").Invoke("api-key", "api-secret", "staging", js.Undefined()) + assert.Empty(t, os.Getenv("MEGAPORT_MANAGED_ACCOUNT_UID")) + js.Global().Get("setAuthCredentials").Invoke("api-key", "api-secret", "staging", "managed-uid-789") + js.Global().Get("setAuthCredentials").Invoke("api-key", "api-secret", "staging", js.Null()) + assert.Empty(t, os.Getenv("MEGAPORT_MANAGED_ACCOUNT_UID")) + + js.Global().Get("setAuthCredentials").Invoke("api-key", "api-secret", "staging", "managed-uid-789") + js.Global().Get("clearAuthCredentials").Invoke() + assert.Empty(t, os.Getenv("MEGAPORT_MANAGED_ACCOUNT_UID")) + + // Switching to token auth must not inherit a UID from a prior credentials + // session: the token path honors only the --on-behalf-of flag. + js.Global().Get("setAuthCredentials").Invoke("api-key", "api-secret", "staging", "managed-uid-789") + js.Global().Get("setAuthToken").Invoke("valid-token-12345", "portal.megaport.com") + assert.Empty(t, os.Getenv("MEGAPORT_MANAGED_ACCOUNT_UID")) +} + // TestSetAuthTokenMasking verifies token preview masking func TestSetAuthTokenMasking(t *testing.T) { RegisterJSFunctions()