From 803c7b149906d0bbcb49a75b499b1b9e71388f81 Mon Sep 17 00:00:00 2001 From: Frank Denis Date: Tue, 12 May 2026 12:52:32 +0200 Subject: [PATCH] [CDTOOL-1332] Honor --profile when resolving the API token The deprecated `--profile` flag (`-o`) was silently ignored during token lookup, so commands ran against the default token regardless of the profile the user named on the command line. Token resolution now consults `--profile` after `--token` and before the environment variable, the manifest profile, and the default token. An unknown profile name is a hard error rather than a silent fallback, so the wrong token can no longer slip through under `--quiet`. --- CHANGELOG.md | 1 + pkg/app/run.go | 6 +- pkg/commands/auth/metadata_test.go | 42 +++++++ pkg/commands/auth/revoke.go | 4 + pkg/commands/auth/revoke_test.go | 46 ++++++++ pkg/commands/auth/show.go | 4 + pkg/commands/auth/token.go | 4 + pkg/commands/auth/token_test.go | 55 +++++++++ pkg/errors/remediation_error.go | 8 ++ pkg/global/global.go | 44 +++++++- pkg/global/global_test.go | 172 +++++++++++++++++++++++++++++ 11 files changed, 380 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db7246c30..639eaa6ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### Bug Fixes: +- fix(auth): honor deprecated `--profile`/`-o` when resolving the API token; an unknown profile name is now a hard error instead of a silent fallback to the default token - fix(text): send deprecation warnings to stderr instead of stdout ([#1782](https://github.com/fastly/cli/pull/1782)) ### Enhancements: diff --git a/pkg/app/run.go b/pkg/app/run.go index 470216d34..4f869362b 100644 --- a/pkg/app/run.go +++ b/pkg/app/run.go @@ -289,7 +289,7 @@ func Exec(data *global.Data) error { data.AuthServer = authServer } - if !data.Flags.Quiet && data.Flags.Token == "" && data.Env.APIToken == "" && data.Manifest != nil && data.Manifest.File.Profile != "" { + if !data.Flags.Quiet && data.Flags.Token == "" && data.Flags.Profile == "" && data.Env.APIToken == "" && data.Manifest != nil && data.Manifest.File.Profile != "" { if data.Config.GetAuthToken(data.Manifest.File.Profile) == nil { if defaultName, _ := data.Config.GetDefaultAuthToken(); defaultName != "" { text.Warning(data.ErrOutput, "fastly.toml profile %q not found in auth config, using default token %q.\n", data.Manifest.File.Profile, defaultName) @@ -397,6 +397,10 @@ func configureKingpin(data *global.Data) *kingpin.Application { // Tokens from --token (raw, unavailable when FASTLY_DISABLE_AUTH_COMMAND is // set) or FASTLY_API_TOKEN are assumed to be valid. func processToken(data *global.Data) (token string, tokenSource lookup.Source, err error) { + if err := data.ValidateProfileFlag(); err != nil { + return "", lookup.SourceUndefined, err + } + token, tokenSource = data.Token() switch tokenSource { diff --git a/pkg/commands/auth/metadata_test.go b/pkg/commands/auth/metadata_test.go index c86b95bae..3dc5a3ae3 100644 --- a/pkg/commands/auth/metadata_test.go +++ b/pkg/commands/auth/metadata_test.go @@ -347,6 +347,48 @@ func TestAuthShow(t *testing.T) { "(default)", }, }, + { + Name: "show with unknown --profile rejected", + Args: "show --profile bogus", + ConfigFile: &config.File{ + Auth: config.Auth{ + Default: "user", + Tokens: config.AuthTokens{ + "user": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "t"}, + }, + }, + }, + WantError: `profile "bogus"`, + WantRemediation: "fastly auth", + }, + { + Name: "show with known --profile uses that profile", + Args: "show --profile alt", + ConfigFile: &config.File{ + Auth: config.Auth{ + Default: "user", + Tokens: config.AuthTokens{ + "user": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "t"}, + "alt": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "alt-token", Email: "a@example.com"}, + }, + }, + }, + WantOutputs: []string{"Name: alt", "a@example.com"}, + }, + { + Name: "show with --token raw --profile bogus skips profile validation", + Args: "show --token raw --profile bogus", + ConfigFile: &config.File{ + Auth: config.Auth{ + Default: "user", + Tokens: config.AuthTokens{ + "user": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "t"}, + }, + }, + }, + WantError: "current token is not stored", + DontWantOutput: "not found in auth config", + }, } testutil.RunCLIScenarios(t, []string{"auth"}, scenarios) diff --git a/pkg/commands/auth/revoke.go b/pkg/commands/auth/revoke.go index b8c31d511..344e81556 100644 --- a/pkg/commands/auth/revoke.go +++ b/pkg/commands/auth/revoke.go @@ -52,6 +52,10 @@ func (c *RevokeCommand) Exec(in io.Reader, out io.Writer) error { return err } + if err := c.Globals.ValidateProfileFlag(); err != nil { + return err + } + switch { case c.current: return c.revokeCurrent(in, out) diff --git a/pkg/commands/auth/revoke_test.go b/pkg/commands/auth/revoke_test.go index c3991d352..cdab21d5f 100644 --- a/pkg/commands/auth/revoke_test.go +++ b/pkg/commands/auth/revoke_test.go @@ -414,6 +414,52 @@ func TestAuthRevoke(t *testing.T) { WantRemediation: "one token ID per line", }, + { + Name: "revoke --current with unknown --profile rejected", + Args: "revoke --current --profile bogus", + WantError: `profile "bogus"`, + WantRemediation: "fastly auth", + }, + { + Name: "revoke --name with unknown --profile rejected", + Args: "revoke --name secondary --profile bogus", + WantError: `profile "bogus"`, + WantRemediation: "fastly auth", + }, + { + Name: "revoke --token-value with unknown --profile rejected", + Args: "revoke --token-value tok-secondary --profile bogus", + WantError: `profile "bogus"`, + WantRemediation: "fastly auth", + }, + { + Name: "revoke --id with unknown --profile rejected", + Args: "revoke --id some-id --profile bogus", + WantError: `profile "bogus"`, + WantRemediation: "fastly auth", + }, + { + Name: "revoke --file with unknown --profile rejected", + Args: fmt.Sprintf("revoke --file %s --profile bogus", writeTokenIDFile(t, "id-1\n")), + WantError: `profile "bogus"`, + WantRemediation: "fastly auth", + }, + { + Name: "revoke --current with --token flag wins over unknown --profile", + Args: "revoke --current --token tok-stored --profile bogus", + API: &mock.API{DeleteTokenSelfFn: deleteTokenSelfOK}, + ConfigFile: &config.File{ + Auth: config.Auth{ + Default: "mytoken", + Tokens: config.AuthTokens{ + "mytoken": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "tok-stored"}, + }, + }, + }, + Stdin: []string{"y"}, + WantOutputs: []string{"Revoked current token"}, + }, + // API client factory failure { Name: "API client factory failure on --name", diff --git a/pkg/commands/auth/show.go b/pkg/commands/auth/show.go index 987ef2af1..21ac3a8ce 100644 --- a/pkg/commands/auth/show.go +++ b/pkg/commands/auth/show.go @@ -32,6 +32,10 @@ func NewShowCommand(parent argparser.Registerer, g *global.Data) *ShowCommand { } func (c *ShowCommand) Exec(_ io.Reader, out io.Writer) error { + if err := c.Globals.ValidateProfileFlag(); err != nil { + return err + } + if c.name == "" { _, src := c.Globals.Token() switch src { diff --git a/pkg/commands/auth/token.go b/pkg/commands/auth/token.go index 281dd7ecf..98a7f4452 100644 --- a/pkg/commands/auth/token.go +++ b/pkg/commands/auth/token.go @@ -33,6 +33,10 @@ func (c *TokenCommand) Exec(_ io.Reader, out io.Writer) error { } } + if err := c.Globals.ValidateProfileFlag(); err != nil { + return err + } + token, src := c.Globals.Token() if src == lookup.SourceUndefined || token == "" { return fsterr.RemediationError{ diff --git a/pkg/commands/auth/token_test.go b/pkg/commands/auth/token_test.go index adb704423..c215253ac 100644 --- a/pkg/commands/auth/token_test.go +++ b/pkg/commands/auth/token_test.go @@ -3,6 +3,7 @@ package auth_test import ( "bytes" "errors" + "strings" "testing" "github.com/fastly/kingpin" @@ -69,3 +70,57 @@ func TestToken_NonTTY_NoToken(t *testing.T) { t.Errorf("unexpected inner error: %v", re.Inner) } } + +func TestToken_NonTTY_ProfileFlagUnknown(t *testing.T) { + var buf bytes.Buffer + g := globalDataWithToken("test-api-token-value") + g.Flags.Profile = "bogus" + + cmd := newTokenCommand(g) + err := cmd.Exec(nil, &buf) + if err == nil { + t.Fatal("expected error for unknown --profile") + } + var re fsterr.RemediationError + if !errors.As(err, &re) { + t.Fatalf("expected RemediationError, got %T: %v", err, err) + } + if re.Inner == nil || !strings.Contains(re.Inner.Error(), `"bogus"`) { + t.Errorf("unexpected inner error: %v", re.Inner) + } + if buf.Len() != 0 { + t.Errorf("expected no token to be written, got: %q", buf.String()) + } +} + +func TestToken_NonTTY_ProfileFlagKnown(t *testing.T) { + var buf bytes.Buffer + g := globalDataWithToken("default-token") + g.Config.Auth.Tokens["alt"] = &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "alt-token"} + g.Flags.Profile = "alt" + + cmd := newTokenCommand(g) + err := cmd.Exec(nil, &buf) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if got := buf.String(); got != "alt-token" { + t.Errorf("expected token %q, got %q", "alt-token", got) + } +} + +func TestToken_NonTTY_TokenFlagBeatsProfileFlag(t *testing.T) { + var buf bytes.Buffer + g := globalDataWithToken("default-token") + g.Flags.Token = "raw-xyz" + g.Flags.Profile = "bogus" + + cmd := newTokenCommand(g) + err := cmd.Exec(nil, &buf) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if got := buf.String(); got != "raw-xyz" { + t.Errorf("expected token %q, got %q", "raw-xyz", got) + } +} diff --git a/pkg/errors/remediation_error.go b/pkg/errors/remediation_error.go index 8140aac90..8679f086b 100644 --- a/pkg/errors/remediation_error.go +++ b/pkg/errors/remediation_error.go @@ -184,6 +184,14 @@ var ComputeBuildRemediation = strings.Join([]string{ "See more at https://www.fastly.com/documentation/reference/compute/fastly-toml", }, " ") +// ErrProfileFlagNotFound is returned when --profile names an unknown auth token. +func ErrProfileFlagNotFound(name string) RemediationError { + return RemediationError{ + Inner: fmt.Errorf("profile %q (from --profile) not found in auth config", name), + Remediation: ProfileRemediation(), + } +} + // ProfileRemediation suggests running auth commands. func ProfileRemediation() string { if env.AuthCommandDisabled() { diff --git a/pkg/global/global.go b/pkg/global/global.go index f8fd67759..eb8b354dc 100644 --- a/pkg/global/global.go +++ b/pkg/global/global.go @@ -97,11 +97,11 @@ type Data struct { // Order of precedence: // - The --token flag (if it matches a stored auth token name, use that token). // - The --token flag (treated as a raw API token). +// - The --profile/-o flag (must match a stored auth token name). // - The FASTLY_API_TOKEN environment variable. // - The `profile` manifest field mapped to an auth token name. // - The default [auth] token (if configured). func (d *Data) Token() (string, lookup.Source) { - // --token: check if it matches a stored auth token name first. if d.Flags.Token != "" { if at := d.Config.GetAuthToken(d.Flags.Token); at != nil && at.Token != "" { return at.Token, lookup.SourceAuth @@ -109,7 +109,13 @@ func (d *Data) Token() (string, lookup.Source) { return d.Flags.Token, lookup.SourceFlag } - // FASTLY_API_TOKEN + if d.Flags.Profile != "" { + if at, ok := d.profileFlagToken(); ok { + return at.Token, lookup.SourceAuth + } + return "", lookup.SourceUndefined + } + if d.Env.APIToken != "" { return d.Env.APIToken, lookup.SourceEnvironment } @@ -120,7 +126,6 @@ func (d *Data) Token() (string, lookup.Source) { } } - // [auth] section default token. if _, at := d.Config.GetDefaultAuthToken(); at != nil && at.Token != "" { return at.Token, lookup.SourceAuth } @@ -128,10 +133,33 @@ func (d *Data) Token() (string, lookup.Source) { return "", lookup.SourceUndefined } +func (d *Data) profileFlagToken() (*config.AuthToken, bool) { + if d.Flags.Profile == "" { + return nil, false + } + at := d.Config.GetAuthToken(d.Flags.Profile) + if at == nil || at.Token == "" { + return nil, false + } + return at, true +} + +// ValidateProfileFlag returns an error if --profile/-o is set to a name that +// does not resolve to a stored auth token. --token outranks --profile and +// short-circuits the check. +func (d *Data) ValidateProfileFlag() error { + if d.Flags.Token != "" || d.Flags.Profile == "" { + return nil + } + if _, ok := d.profileFlagToken(); ok { + return nil + } + return fsterr.ErrProfileFlagNotFound(d.Flags.Profile) +} + // AuthTokenName returns the name of the auth token being used, if any. // This is used for display purposes and for SSO refresh of named tokens. func (d *Data) AuthTokenName() string { - // If --token matches a stored auth token name, return that name. if d.Flags.Token != "" { if at := d.Config.GetAuthToken(d.Flags.Token); at != nil { return d.Flags.Token @@ -139,12 +167,18 @@ func (d *Data) AuthTokenName() string { return "" } + if d.Flags.Profile != "" { + if _, ok := d.profileFlagToken(); ok { + return d.Flags.Profile + } + return "" + } + if d.Manifest != nil && d.Manifest.File.Profile != "" { if at := d.Config.GetAuthToken(d.Manifest.File.Profile); at != nil { return d.Manifest.File.Profile } } - // Otherwise return the default auth token name. name, _ := d.Config.GetDefaultAuthToken() return name } diff --git a/pkg/global/global_test.go b/pkg/global/global_test.go index 873cebde7..957cc39db 100644 --- a/pkg/global/global_test.go +++ b/pkg/global/global_test.go @@ -131,6 +131,127 @@ func TestToken(t *testing.T) { wantToken: "", wantSource: lookup.SourceUndefined, }, + { + name: "profile flag matches stored auth token", + data: &global.Data{ + Flags: global.Flags{Profile: "alt"}, + Config: config.File{ + Auth: config.Auth{ + Default: "user", + Tokens: config.AuthTokens{ + "user": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "default-token"}, + "alt": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "alt-token"}, + }, + }, + }, + }, + wantToken: "alt-token", + wantSource: lookup.SourceAuth, + }, + { + name: "profile flag unknown does not fall back even when env/manifest/default exist", + data: &global.Data{ + Flags: global.Flags{Profile: "missing"}, + Env: config.Environment{APIToken: "env-token"}, + Manifest: &manifest.Data{ + File: manifest.File{Profile: "proj"}, + }, + Config: config.File{ + Auth: config.Auth{ + Default: "user", + Tokens: config.AuthTokens{ + "user": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "default-token"}, + "proj": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "project-token"}, + }, + }, + }, + }, + wantToken: "", + wantSource: lookup.SourceUndefined, + }, + { + name: "profile flag matches stored entry with empty token returns undefined", + data: &global.Data{ + Flags: global.Flags{Profile: "blank"}, + Config: config.File{ + Auth: config.Auth{ + Default: "user", + Tokens: config.AuthTokens{ + "user": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "default-token"}, + "blank": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: ""}, + }, + }, + }, + }, + wantToken: "", + wantSource: lookup.SourceUndefined, + }, + { + name: "token flag raw value wins over profile flag", + data: &global.Data{ + Flags: global.Flags{Token: "raw-xyz", Profile: "alt"}, + Config: config.File{ + Auth: config.Auth{ + Tokens: config.AuthTokens{ + "alt": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "alt-token"}, + }, + }, + }, + }, + wantToken: "raw-xyz", + wantSource: lookup.SourceFlag, + }, + { + name: "token flag matching stored name wins over profile flag", + data: &global.Data{ + Flags: global.Flags{Token: "primary", Profile: "alt"}, + Config: config.File{ + Auth: config.Auth{ + Tokens: config.AuthTokens{ + "primary": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "primary-token"}, + "alt": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "alt-token"}, + }, + }, + }, + }, + wantToken: "primary-token", + wantSource: lookup.SourceAuth, + }, + { + name: "profile flag wins over env var", + data: &global.Data{ + Flags: global.Flags{Profile: "alt"}, + Env: config.Environment{APIToken: "env-token"}, + Config: config.File{ + Auth: config.Auth{ + Tokens: config.AuthTokens{ + "alt": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "alt-token"}, + }, + }, + }, + }, + wantToken: "alt-token", + wantSource: lookup.SourceAuth, + }, + { + name: "profile flag wins over manifest profile", + data: &global.Data{ + Flags: global.Flags{Profile: "alt"}, + Manifest: &manifest.Data{ + File: manifest.File{Profile: "proj"}, + }, + Config: config.File{ + Auth: config.Auth{ + Tokens: config.AuthTokens{ + "alt": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "alt-token"}, + "proj": &config.AuthToken{Type: config.AuthTokenTypeStatic, Token: "project-token"}, + }, + }, + }, + }, + wantToken: "alt-token", + wantSource: lookup.SourceAuth, + }, } for _, tt := range tests { @@ -229,6 +350,57 @@ func TestAuthTokenName(t *testing.T) { }, wantName: "user", }, + { + name: "profile flag matches stored name with non-empty token", + data: &global.Data{ + Flags: global.Flags{Profile: "alt"}, + Config: config.File{ + Auth: config.Auth{ + Default: "user", + Tokens: config.AuthTokens{ + "user": &config.AuthToken{Token: "t"}, + "alt": &config.AuthToken{Token: "alt-token"}, + }, + }, + }, + }, + wantName: "alt", + }, + { + name: "profile flag matches stored entry with empty token returns empty", + data: &global.Data{ + Flags: global.Flags{Profile: "blank"}, + Config: config.File{ + Auth: config.Auth{ + Default: "user", + Tokens: config.AuthTokens{ + "user": &config.AuthToken{Token: "t"}, + "blank": &config.AuthToken{Token: ""}, + }, + }, + }, + }, + wantName: "", + }, + { + name: "profile flag unknown returns empty without falling through", + data: &global.Data{ + Flags: global.Flags{Profile: "missing"}, + Manifest: &manifest.Data{ + File: manifest.File{Profile: "proj"}, + }, + Config: config.File{ + Auth: config.Auth{ + Default: "user", + Tokens: config.AuthTokens{ + "user": &config.AuthToken{Token: "t"}, + "proj": &config.AuthToken{Token: "t2"}, + }, + }, + }, + }, + wantName: "", + }, } for _, tt := range tests {