Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/megaport/common_wasm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions cmd/megaport/megaport_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions docs/megaport-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
8 changes: 6 additions & 2 deletions frontend-integration/types/megaport-wasm.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

/**
Expand Down Expand Up @@ -332,7 +335,8 @@ declare global {
setAuthCredentials?: (
accessKey: string,
secretKey: string,
environment: string
environment: string,
managedAccountUID?: string
) => { success: boolean; error?: string };
setAuthToken?: (
token: string,
Expand Down
14 changes: 14 additions & 0 deletions internal/commands/config/config_shared.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 9 additions & 1 deletion internal/commands/config/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down
96 changes: 96 additions & 0 deletions internal/commands/config/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions internal/commands/config/login_wasm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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")
Expand Down
5 changes: 5 additions & 0 deletions internal/utils/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
)
Expand Down
16 changes: 16 additions & 0 deletions internal/wasm/wasm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<undefined>"/"<null>" 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.
Expand All @@ -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")
Expand Down Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions internal/wasm/wasm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ package wasm
import (
"bytes"
"math"
"os"
"strings"
"syscall/js"
"testing"
Expand Down Expand Up @@ -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
// "<undefined>"/"<null>" 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()
Expand Down
Loading