diff --git a/auth/auth.go b/auth/auth.go index d361f1e..a236760 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -3,12 +3,15 @@ package auth import ( "context" + "errors" "fmt" "net/http" "os" + "strings" "golang.org/x/oauth2" "golang.org/x/oauth2/google" + "google.golang.org/api/googleapi" "github.com/open-cli-collective/google-cli-common/config" "github.com/open-cli-collective/google-cli-common/keychain" @@ -90,9 +93,11 @@ func GetHTTPClient(ctx context.Context) (*http.Client, error) { tok, err := st.Token() if err != nil { _ = st.Close() - return nil, fmt.Errorf("no OAuth token found - please run '%s init' first: %w", config.ProductName(), err) + return nil, fmt.Errorf("no OAuth token stored for credential %s (selected via %s) - run '%s init' first: %w", + st.Ref(), keychain.DescribeRefSource(st.RefSource()), config.ProductName(), err) } ref := st.Ref() + refSource := st.RefSource() _ = st.Close() // do not hold the Store for the client's lifetime persist := func(t *oauth2.Token) error { @@ -105,7 +110,64 @@ func GetHTTPClient(ctx context.Context) (*http.Client, error) { } tokenSource := keychain.NewPersistentTokenSource(ctx, oauthCfg, tok, persist) - return oauth2.NewClient(ctx, tokenSource), nil + return oauth2.NewClient(ctx, &attributedTokenSource{base: tokenSource, ref: ref, source: refSource}), nil +} + +// attributedTokenSource decorates auth failures from the wrapped source with +// the credential ref that failed and where that ref was selected. Without +// this, an expired/revoked refresh token surfaces as a bare +// `oauth2: "invalid_grant" ...` — which reads as "the tool is broken" when +// the real state is "this one profile is stale and others may be fine". +// Non-auth errors (network, API outages) pass through untouched: naming the +// profile there would misattribute an infrastructure failure to a credential. +type attributedTokenSource struct { + base oauth2.TokenSource + ref string + source config.RefSource +} + +func (a *attributedTokenSource) Token() (*oauth2.Token, error) { + tok, err := a.base.Token() + if err != nil && IsAuthError(err) { + prod := config.ProductName() + return nil, fmt.Errorf("credential %s (selected via %s) can no longer authenticate: %w; other profiles may be unaffected - run '%s profiles list' to check them, or '%s init' to re-authenticate this one", + a.ref, keychain.DescribeRefSource(a.source), err, prod, prod) + } + return tok, err +} + +// IsAuthError reports whether err means the stored token no longer +// authenticates — i.e. re-auth is the fix — as opposed to a transient +// network/API failure. Shared by init's re-auth gate and the runtime +// error-attribution wrapper so "what counts as an auth failure" has exactly +// one definition. +func IsAuthError(err error) bool { + if err == nil { + return false + } + // An expired/revoked refresh token fails inside the oauth2 transport + // (the request never reaches the API): the token endpoint returns HTTP + // 400 with error code "invalid_grant", surfaced as *oauth2.RetrieveError. + // Testing-mode OAuth apps expire their refresh tokens every 7 days, and + // the resulting error carries no 401 for the fallback below. + var retrieveErr *oauth2.RetrieveError + if ok := errors.As(err, &retrieveErr); ok && retrieveErr.ErrorCode == "invalid_grant" { + return true + } + var apiErr *googleapi.Error + if ok := errors.As(err, &apiErr); ok { + return apiErr.Code == http.StatusUnauthorized + } + // String fallback for wrapped/legacy error shapes. "invalid_grant" and + // "Token has been expired or revoked" only ever come from the OAuth + // token endpoint's error response, so they are safe to treat as + // definitive without a status code; a bare 401 still needs corroboration. + errStr := err.Error() + if strings.Contains(errStr, "invalid_grant") || + strings.Contains(errStr, "Token has been expired or revoked") { + return true + } + return strings.Contains(errStr, "401") && strings.Contains(errStr, "Invalid Credentials") } // GetAuthURL returns the OAuth authorization URL for the given config diff --git a/auth/autherror_test.go b/auth/autherror_test.go new file mode 100644 index 0000000..5063ceb --- /dev/null +++ b/auth/autherror_test.go @@ -0,0 +1,133 @@ +package auth + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "testing" + + "golang.org/x/oauth2" + "google.golang.org/api/googleapi" + + "github.com/open-cli-collective/google-cli-common/config" +) + +// TestIsAuthError pins the auth-vs-transient classification. Moved from +// initcmd when the helper was promoted to this package so init's re-auth gate +// and the runtime error-attribution wrapper share one definition. +func TestIsAuthError(t *testing.T) { + t.Parallel() + tests := []struct { + name string + err error + expected bool + }{ + {"nil error", nil, false}, + {"generic error", errors.New("something went wrong"), false}, + {"network error", errors.New("connection refused"), false}, + {"googleapi 401", &googleapi.Error{Code: http.StatusUnauthorized, Message: "Invalid Credentials"}, true}, + {"googleapi 403", &googleapi.Error{Code: http.StatusForbidden, Message: "Access denied"}, false}, + {"googleapi 404", &googleapi.Error{Code: http.StatusNotFound, Message: "Not found"}, false}, + {"text 401 + Invalid Credentials", errors.New("googleapi: Error 401: Invalid Credentials"), true}, + {"text 401 + invalid_grant", errors.New("oauth2: 401 invalid_grant: Token has been expired"), true}, + {"text Token has been expired or revoked", errors.New("401: Token has been expired or revoked"), true}, + {"text 401 alone", errors.New("HTTP 401 response"), false}, + // An expired/revoked refresh token fails token *refresh* (HTTP 400 + // invalid_grant from the token endpoint), so it carries no 401 — + // this is the shape a Testing-mode OAuth app produces every 7 days. + {"RetrieveError invalid_grant", &oauth2.RetrieveError{ + Response: &http.Response{StatusCode: http.StatusBadRequest}, + ErrorCode: "invalid_grant", + ErrorDescription: "Token has been expired or revoked.", + }, true}, + {"RetrieveError invalid_grant wrapped like production", fmt.Errorf("getting profile: %w", + &url.Error{Op: "Get", URL: "https://gmail.googleapis.com/gmail/v1/users/me/profile", Err: &oauth2.RetrieveError{ + Response: &http.Response{StatusCode: http.StatusBadRequest}, + ErrorCode: "invalid_grant", + }}), true}, + {"RetrieveError other code", &oauth2.RetrieveError{ + Response: &http.Response{StatusCode: http.StatusServiceUnavailable}, + ErrorCode: "temporarily_unavailable", + }, false}, + {"text invalid_grant without 401", errors.New(`oauth2: "invalid_grant" "Token has been expired or revoked."`), true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := IsAuthError(tt.err); got != tt.expected { + t.Errorf("IsAuthError(%v) = %v, want %v", tt.err, got, tt.expected) + } + }) + } +} + +// staticErrSource is a TokenSource that always fails with a fixed error. +type staticErrSource struct{ err error } + +func (s staticErrSource) Token() (*oauth2.Token, error) { return nil, s.err } + +// TestAttributedTokenSource_NamesRefOnAuthError is the regression test for +// the mis-diagnosis this wrapper exists to prevent: a bare +// `oauth2: "invalid_grant"` with no profile name read as "the tool is dead" +// when only the active profile's token was stale. +func TestAttributedTokenSource_NamesRefOnAuthError(t *testing.T) { + base := staticErrSource{err: &oauth2.RetrieveError{ + Response: &http.Response{StatusCode: http.StatusBadRequest}, + ErrorCode: "invalid_grant", + ErrorDescription: "Token has been expired or revoked.", + }} + ats := &attributedTokenSource{base: base, ref: "google-readonly/default", source: config.RefSourceConfig} + + _, err := ats.Token() + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + for _, want := range []string{ + "credential google-readonly/default", + "config.yml credential_ref", + "invalid_grant", + "profiles list", + "init", + } { + if !strings.Contains(msg, want) { + t.Errorf("error %q missing %q", msg, want) + } + } + // The original error must stay errors.As-able through the wrap so + // callers classifying with IsAuthError (or inspecting RetrieveError) + // still work. + var retrieveErr *oauth2.RetrieveError + if !errors.As(err, &retrieveErr) { + t.Errorf("wrapped error lost the underlying *oauth2.RetrieveError") + } +} + +// TestAttributedTokenSource_PassesThroughNonAuthErrors proves a network-class +// failure is NOT attributed to the credential (that would misdiagnose an +// outage as a stale profile). +func TestAttributedTokenSource_PassesThroughNonAuthErrors(t *testing.T) { + netErr := errors.New("dial tcp: connection refused") + ats := &attributedTokenSource{base: staticErrSource{err: netErr}, ref: "google-readonly/default", source: config.RefSourceConfig} + + _, err := ats.Token() + if !errors.Is(err, netErr) { + t.Fatalf("err = %v, want the original error", err) + } + if strings.Contains(err.Error(), "credential ") { + t.Errorf("non-auth error must not be attributed to a credential: %q", err) + } +} + +// TestAttributedTokenSource_PassesThroughSuccess proves the happy path is +// untouched. +func TestAttributedTokenSource_PassesThroughSuccess(t *testing.T) { + want := &oauth2.Token{AccessToken: "at"} + ats := &attributedTokenSource{base: oauth2.StaticTokenSource(want), ref: "google-readonly/default", source: config.RefSourceDefault} + got, err := ats.Token() + if err != nil || got.AccessToken != want.AccessToken { + t.Fatalf("Token() = (%v, %v), want (%v, nil)", got, err, want) + } +} diff --git a/config/config.go b/config/config.go index ff192cb..bd2d62c 100644 --- a/config/config.go +++ b/config/config.go @@ -76,8 +76,40 @@ type Config struct { GrantedScopes []string `yaml:"granted_scopes,omitempty" json:"granted_scopes,omitempty"` // Keyring carries the optional §1.4 explicit file-backend opt-in. Keyring KeyringConfig `yaml:"keyring,omitempty" json:"-"` + + // credentialRefSource records where the resolved CredentialRef came from + // (config.yml vs the built-in default; the keychain layer upgrades it to + // flag/env when a per-invocation override applies). Unexported so it is + // never serialized: it is provenance for error attribution and `config + // show`, not configuration. + credentialRefSource RefSource } +// RefSource identifies where the resolved CredentialRef came from, so auth +// errors and `config show` can attribute the active profile to its source +// instead of leaving the user to guess which of flag/env/config selected it. +type RefSource string + +// RefSource values, in precedence order (flag > env > config > default). +// RefSourceExplicit marks a caller-supplied ref (set-credential --ref, the +// refresh persister) that bypassed the precedence chain. +const ( + RefSourceFlag RefSource = "flag" + RefSourceEnv RefSource = "env" + RefSourceConfig RefSource = "config" + RefSourceDefault RefSource = "default" + RefSourceExplicit RefSource = "explicit" +) + +// CredentialRefSource returns the recorded provenance of CredentialRef. +// Empty for a Config constructed directly (tests) rather than loaded. +func (c *Config) CredentialRefSource() RefSource { return c.credentialRefSource } + +// SetCredentialRefSource records CredentialRef provenance. Called by the +// keychain layer when a per-invocation override (flag/env) or an explicit +// caller-supplied ref replaces the loaded value. +func (c *Config) SetCredentialRefSource(s RefSource) { c.credentialRefSource = s } + // KeyringConfig is the §1.4 backend selector. Backend == "file" forces the // encrypted-file backend; empty means OS default selection (fail-closed on // Linux when no Secret Service is available). @@ -349,6 +381,9 @@ func loadLegacyJSON(cfg *Config) error { func (c *Config) applyDefaults() { if c.CredentialRef == "" { c.CredentialRef = DefaultCredentialRef + c.credentialRefSource = RefSourceDefault + } else { + c.credentialRefSource = RefSourceConfig } if c.OAuthClientPath == "" { if p, err := DefaultOAuthClientPath(); err == nil { diff --git a/configcmd/config.go b/configcmd/config.go index f46f076..7a8715a 100644 --- a/configcmd/config.go +++ b/configcmd/config.go @@ -125,6 +125,7 @@ The OAuth client JSON (deployment material) is never removed.`, // masked prefix. type showStatus struct { CredentialRef string `json:"credential_ref"` + CredentialRefSource string `json:"credential_ref_source,omitempty"` Backend string `json:"backend"` BackendSource string `json:"backend_source"` KeyringBackend string `json:"keyring_backend,omitempty"` // selector from config.yml (keyring.backend) @@ -157,13 +158,14 @@ func runShow(jsonOut, verbose bool) error { } backend, src := st.Backend() status := showStatus{ - CredentialRef: st.Ref(), - Backend: string(backend), - BackendSource: string(src), - KeyringBackend: cfg.Keyring.Backend, // selector value from config.yml; "" if unset - OAuthTokenPresent: hasTok, - OAuthClientPath: config.ShortenPath(cfg.OAuthClientPath), - OAuthClientPresent: false, + CredentialRef: st.Ref(), + CredentialRefSource: string(st.RefSource()), + Backend: string(backend), + BackendSource: string(src), + KeyringBackend: cfg.Keyring.Backend, // selector value from config.yml; "" if unset + OAuthTokenPresent: hasTok, + OAuthClientPath: config.ShortenPath(cfg.OAuthClientPath), + OAuthClientPresent: false, } if backend == credstore.BackendFile { status.PassphraseSource = keychain.PassphraseSource(st.Service()) @@ -180,7 +182,7 @@ func runShow(jsonOut, verbose bool) error { return output.JSONStdout(status) } - fmt.Printf("Credential ref: %s\n", status.CredentialRef) + fmt.Printf("Credential ref: %s (via %s)\n", status.CredentialRef, keychain.DescribeRefSource(st.RefSource())) fmt.Printf("Backend: %s (%s)\n", status.Backend, status.BackendSource) if status.KeyringBackend != "" { fmt.Printf("keyring.backend: %s (config.yml)\n", status.KeyringBackend) diff --git a/initcmd/init.go b/initcmd/init.go index 4029713..f8a7dbc 100644 --- a/initcmd/init.go +++ b/initcmd/init.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "io" - "net/http" "net/url" "os" "path/filepath" @@ -19,7 +18,6 @@ import ( "github.com/spf13/cobra" "golang.org/x/oauth2" "golang.org/x/oauth2/google" - "google.golang.org/api/googleapi" "github.com/open-cli-collective/google-cli-common/auth" "github.com/open-cli-collective/google-cli-common/config" @@ -525,7 +523,7 @@ func tryExistingToken(ctx context.Context, d initDeps, opts *initOptions) (bool, email, err := d.GmailVerify(ctx) if err != nil { - if isAuthError(err) { + if auth.IsAuthError(err) { d.View.Error("Stored token is expired or revoked.") if err := promptAndDeleteForReauth(d, opts); err != nil { return false, err @@ -769,41 +767,6 @@ func extractAuthCode(input string) string { return input } -// isAuthError reports whether err means the stored token no longer -// authenticates — i.e. re-auth is the fix — as opposed to a transient -// network/API failure. -func isAuthError(err error) bool { - if err == nil { - return false - } - // An expired/revoked refresh token fails inside the oauth2 transport - // (the request never reaches the API): the token endpoint returns HTTP - // 400 with error code "invalid_grant", surfaced as *oauth2.RetrieveError. - // Without this branch, init hard-fails on the exact scenario it exists - // to fix — Testing-mode OAuth apps expire their refresh tokens every 7 - // days, and the resulting error carries no 401 for the fallback below. - var retrieveErr *oauth2.RetrieveError - if ok := errorAs(err, &retrieveErr); ok && retrieveErr.ErrorCode == "invalid_grant" { - return true - } - var apiErr *googleapi.Error - if ok := errorAs(err, &apiErr); ok { - return apiErr.Code == http.StatusUnauthorized - } - // String fallback for wrapped/legacy error shapes. "invalid_grant" and - // "Token has been expired or revoked" only ever come from the OAuth - // token endpoint's error response, so they are safe to treat as - // definitive without a status code; a bare 401 still needs corroboration. - errStr := err.Error() - if strings.Contains(errStr, "invalid_grant") || - strings.Contains(errStr, "Token has been expired or revoked") { - return true - } - return strings.Contains(errStr, "401") && strings.Contains(errStr, "Invalid Credentials") -} - -var errorAs = errors.As - // workspaceAdminsURL points to the repo's Workspace-admin walkthrough. // Referenced from both cmd.Long and the runtime wizard, so installed-CLI // users (Homebrew/Chocolatey/Winget) reach it without a local checkout. diff --git a/initcmd/init_test.go b/initcmd/init_test.go index 3b1c65a..eb7b584 100644 --- a/initcmd/init_test.go +++ b/initcmd/init_test.go @@ -99,50 +99,6 @@ func TestExtractAuthCode(t *testing.T) { } } -func TestIsAuthError(t *testing.T) { - t.Parallel() - tests := []struct { - name string - err error - expected bool - }{ - {"nil error", nil, false}, - {"generic error", errors.New("something went wrong"), false}, - {"network error", errors.New("connection refused"), false}, - {"googleapi 401", &googleapi.Error{Code: http.StatusUnauthorized, Message: "Invalid Credentials"}, true}, - {"googleapi 403", &googleapi.Error{Code: http.StatusForbidden, Message: "Access denied"}, false}, - {"googleapi 404", &googleapi.Error{Code: http.StatusNotFound, Message: "Not found"}, false}, - {"text 401 + Invalid Credentials", errors.New("googleapi: Error 401: Invalid Credentials"), true}, - {"text 401 + invalid_grant", errors.New("oauth2: 401 invalid_grant: Token has been expired"), true}, - {"text Token has been expired or revoked", errors.New("401: Token has been expired or revoked"), true}, - {"text 401 alone", errors.New("HTTP 401 response"), false}, - // An expired/revoked refresh token fails token *refresh* (HTTP 400 - // invalid_grant from the token endpoint), so it carries no 401 — - // this is the shape a Testing-mode OAuth app produces every 7 days. - {"RetrieveError invalid_grant", &oauth2.RetrieveError{ - Response: &http.Response{StatusCode: http.StatusBadRequest}, - ErrorCode: "invalid_grant", - ErrorDescription: "Token has been expired or revoked.", - }, true}, - {"RetrieveError invalid_grant wrapped like production", fmt.Errorf("getting profile: %w", - &url.Error{Op: "Get", URL: "https://gmail.googleapis.com/gmail/v1/users/me/profile", Err: &oauth2.RetrieveError{ - Response: &http.Response{StatusCode: http.StatusBadRequest}, - ErrorCode: "invalid_grant", - }}), true}, - {"RetrieveError other code", &oauth2.RetrieveError{ - Response: &http.Response{StatusCode: http.StatusServiceUnavailable}, - ErrorCode: "temporarily_unavailable", - }, false}, - {"text invalid_grant without 401", errors.New(`oauth2: "invalid_grant" "Token has been expired or revoked."`), true}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - testutil.Equal(t, isAuthError(tt.err), tt.expected) - }) - } -} - func TestValidateOAuthJSONRejectsGarbage(t *testing.T) { t.Parallel() if err := validateOAuthJSON("not json"); err == nil { diff --git a/keychain/credref_test.go b/keychain/credref_test.go index 9747307..0a42741 100644 --- a/keychain/credref_test.go +++ b/keychain/credref_test.go @@ -30,18 +30,18 @@ func TestEffectiveRef_Precedence(t *testing.T) { t.Run("config only", func(t *testing.T) { resetCredRefOverride(t) t.Setenv(CredentialRefEnvVar(), "") - ref, ov := effectiveRef(cfgRef) - if ref != cfgRef || ov { - t.Errorf("got (%q,%v), want (%q,false)", ref, ov, cfgRef) + ref, src, ov := effectiveRef(cfgRef) + if ref != cfgRef || ov || src != "" { + t.Errorf("got (%q,%q,%v), want (%q,\"\",false)", ref, src, ov, cfgRef) } }) t.Run("env overrides config", func(t *testing.T) { resetCredRefOverride(t) t.Setenv(CredentialRefEnvVar(), "google-readonly/env") - ref, ov := effectiveRef(cfgRef) - if ref != "google-readonly/env" || !ov { - t.Errorf("got (%q,%v), want (google-readonly/env,true)", ref, ov) + ref, src, ov := effectiveRef(cfgRef) + if ref != "google-readonly/env" || !ov || src != config.RefSourceEnv { + t.Errorf("got (%q,%q,%v), want (google-readonly/env,env,true)", ref, src, ov) } }) @@ -49,9 +49,9 @@ func TestEffectiveRef_Precedence(t *testing.T) { resetCredRefOverride(t) t.Setenv(CredentialRefEnvVar(), "google-readonly/env") SetCredentialRefOverride("google-readonly/flag", true) - ref, ov := effectiveRef(cfgRef) - if ref != "google-readonly/flag" || !ov { - t.Errorf("got (%q,%v), want (google-readonly/flag,true)", ref, ov) + ref, src, ov := effectiveRef(cfgRef) + if ref != "google-readonly/flag" || !ov || src != config.RefSourceFlag { + t.Errorf("got (%q,%q,%v), want (google-readonly/flag,flag,true)", ref, src, ov) } }) @@ -59,7 +59,7 @@ func TestEffectiveRef_Precedence(t *testing.T) { resetCredRefOverride(t) t.Setenv(CredentialRefEnvVar(), "") SetCredentialRefOverride("", true) // Changed=true but no value - ref, ov := effectiveRef(cfgRef) + ref, _, ov := effectiveRef(cfgRef) if ref != cfgRef || ov { t.Errorf("got (%q,%v), want (%q,false) — empty --ref must fall through", ref, ov, cfgRef) } @@ -101,6 +101,9 @@ func TestApplyCredentialRefOverride(t *testing.T) { if cfg.CredentialRef != "google-readonly/flag" { t.Errorf("cfg.CredentialRef = %q, want google-readonly/flag", cfg.CredentialRef) } + if src := cfg.CredentialRefSource(); src != config.RefSourceFlag { + t.Errorf("CredentialRefSource = %q, want flag", src) + } }) t.Run("env override: cfg swapped, migration suppressed", func(t *testing.T) { @@ -113,5 +116,29 @@ func TestApplyCredentialRefOverride(t *testing.T) { if cfg.CredentialRef != "google-readonly/env" { t.Errorf("cfg.CredentialRef = %q, want google-readonly/env", cfg.CredentialRef) } + if src := cfg.CredentialRefSource(); src != config.RefSourceEnv { + t.Errorf("CredentialRefSource = %q, want env", src) + } }) } + +// TestDescribeRefSource pins the human labels used in attributed auth errors +// and `config show` — including the dynamically derived env-var name. +func TestDescribeRefSource(t *testing.T) { + cases := []struct { + src config.RefSource + want string + }{ + {config.RefSourceFlag, "--ref flag"}, + {config.RefSourceEnv, "GOOGLE_READONLY_CREDENTIAL_REF environment variable"}, + {config.RefSourceConfig, "config.yml credential_ref"}, + {config.RefSourceDefault, "built-in default; config.yml sets no credential_ref"}, + {config.RefSourceExplicit, "explicitly selected ref"}, + {config.RefSource(""), "unknown source"}, + } + for _, tc := range cases { + if got := DescribeRefSource(tc.src); got != tc.want { + t.Errorf("DescribeRefSource(%q) = %q, want %q", tc.src, got, tc.want) + } + } +} diff --git a/keychain/keychain.go b/keychain/keychain.go index ca736e8..671e06b 100644 --- a/keychain/keychain.go +++ b/keychain/keychain.go @@ -43,10 +43,11 @@ var ErrTokenNotFound = errors.New("no token found in secure storage") // can report it in `config show` / errors without re-deriving it (the ref is // not secret — §1.12). type Store struct { - cs *credstore.Store - service string - profile string - ref string + cs *credstore.Store + service string + profile string + ref string + refSource config.RefSource } // Open resolves the authoritative credential_ref from config.yml (§1.3 — the @@ -90,8 +91,9 @@ func open(overwrite, runMigration bool) (*Store, error) { // safety-critical swap+suppression is directly testable without // config.LoadConfigForRuntime or a real keyring. func applyCredentialRefOverride(cfg *config.Config, runMigration bool) bool { - if ref, overridden := effectiveRef(cfg.CredentialRef); overridden { + if ref, source, overridden := effectiveRef(cfg.CredentialRef); overridden { cfg.CredentialRef = ref + cfg.SetCredentialRefSource(source) return false } return runMigration @@ -113,17 +115,18 @@ func CredentialRefEnvVar() string { // effectiveRef applies the per-invocation credential-ref precedence // (--ref flag > _CREDENTIAL_REF env > config credential_ref) and -// reports whether an explicit override was supplied. Mirrors the --backend -// precedence chain. When overridden is true, the caller must skip the one-time -// §1.8 migration (see open / OpenRef). -func effectiveRef(configRef string) (ref string, overridden bool) { +// reports whether an explicit override was supplied, plus which source won +// (for error attribution). Mirrors the --backend precedence chain. When +// overridden is true, the caller must skip the one-time §1.8 migration (see +// open / OpenRef). +func effectiveRef(configRef string) (ref string, source config.RefSource, overridden bool) { if v, set := GetCredentialRefOverride(); set && v != "" { - return v, true + return v, config.RefSourceFlag, true } if v := os.Getenv(CredentialRefEnvVar()); v != "" { - return v, true + return v, config.RefSourceEnv, true } - return configRef, false + return configRef, "", false } // OpenRef opens a store against an explicit ref instead of config.yml's @@ -140,6 +143,7 @@ func OpenRef(ref string) (*Store, error) { } if ref != "" { cfg.CredentialRef = ref + cfg.SetCredentialRefSource(config.RefSourceExplicit) } return openWith(cfg, false, false) } @@ -178,7 +182,7 @@ func openWith(cfg *config.Config, overwrite, runMigration bool) (*Store, error) return nil, err } - s := &Store{cs: cs, service: service, profile: profile, ref: cfg.CredentialRef} + s := &Store{cs: cs, service: service, profile: profile, ref: cfg.CredentialRef, refSource: cfg.CredentialRefSource()} if runMigration { if err := migrateLegacyOverwrite(s, cfg, overwrite); err != nil { @@ -200,6 +204,31 @@ func (s *Store) Close() error { // Ref returns the resolved credential ref (non-secret; safe to display). func (s *Store) Ref() string { return s.ref } +// RefSource returns where the resolved ref came from (flag/env/config/ +// default/explicit). Empty when the Store was opened from an injected test +// config that never went through load-time provenance stamping. +func (s *Store) RefSource() config.RefSource { return s.refSource } + +// DescribeRefSource renders a RefSource as the human label used in error +// messages and `config show` — e.g. "config.yml credential_ref" or the +// resolved _CREDENTIAL_REF env-var name. It lives here (not in +// config) because the env-var name derives from the credstore service. +func DescribeRefSource(s config.RefSource) string { + switch s { + case config.RefSourceFlag: + return "--ref flag" + case config.RefSourceEnv: + return CredentialRefEnvVar() + " environment variable" + case config.RefSourceConfig: + return "config.yml credential_ref" + case config.RefSourceDefault: + return "built-in default; config.yml sets no credential_ref" + case config.RefSourceExplicit: + return "explicitly selected ref" + } + return "unknown source" +} + // Service returns the resolved service segment (non-secret; used for the // §1.4 passphrase-source label). func (s *Store) Service() string { return s.service }