From 4b186e3f4d837e0f9f6046b17b5c137bc9fffd90 Mon Sep 17 00:00:00 2001 From: Cody Lee Date: Wed, 1 Jul 2026 16:23:48 -0500 Subject: [PATCH] feat(auth): context-scoped multi-account resolution Automatically select which authenticated account gh acts as based on local, API-free context (working directory and base-repo owner) instead of the single global active account. This addresses cli/cli#13768: multiple accounts on the same host (e.g. an EMU + a personal github.com identity) cannot be disambiguated by host, and `gh auth switch` mutates global state, which is unsafe for concurrent agents. New `account_rules` config key (gitdir + owner maps) and an explicit `--account` / `GH_ACCOUNT` override. Resolution wraps the HTTP client's token getter and never mutates persistent state. Token precedence: env token (GH_TOKEN etc.) > --account/GH_ACCOUNT > rules > globally active account. When no rules or override are configured, behavior is byte-identical to before. `gh auth status` now shows which account resolves for the current context, and GH_DEBUG traces resolution, so automatic switching is observable rather than silent. Co-Authored-By: Claude Opus 4.8 --- internal/accountrules/accountrules.go | 149 +++++++++++++++++ internal/accountrules/accountrules_test.go | 157 ++++++++++++++++++ internal/config/auth_config_test.go | 35 ++++ internal/config/config.go | 41 +++++ internal/gh/gh.go | 27 +++ .../verify/verify_integration_test.go | 4 + pkg/cmd/auth/status/status.go | 47 ++++++ pkg/cmd/config/config.go | 24 +++ pkg/cmd/factory/account_resolver.go | 130 +++++++++++++++ pkg/cmd/factory/account_resolver_test.go | 152 +++++++++++++++++ pkg/cmd/factory/default.go | 13 +- pkg/cmd/factory/default_test.go | 2 +- pkg/cmd/root/help_topic.go | 7 + pkg/cmd/root/root.go | 8 + 14 files changed, 792 insertions(+), 4 deletions(-) create mode 100644 internal/accountrules/accountrules.go create mode 100644 internal/accountrules/accountrules_test.go create mode 100644 pkg/cmd/factory/account_resolver.go create mode 100644 pkg/cmd/factory/account_resolver_test.go diff --git a/internal/accountrules/accountrules.go b/internal/accountrules/accountrules.go new file mode 100644 index 00000000000..16ce4745a3f --- /dev/null +++ b/internal/accountrules/accountrules.go @@ -0,0 +1,149 @@ +// Package accountrules resolves which authenticated account gh should act as for +// a given command, based on local, API-free context (the current working +// directory and the base repository owner) plus an optional explicit override. +// +// This makes multi-account usage automatic and context-scoped, similar to git's +// includeIf mechanism, instead of relying on the single global "active" account. +// Resolution never mutates any persistent state. +package accountrules + +import ( + "os" + "path/filepath" + "strings" + + "github.com/cli/cli/v2/internal/gh" +) + +// flagOverride holds the value of the global --account flag. It is set once +// during command execution and read lazily wherever account resolution happens. +var flagOverride string + +// SetOverride records the value of the --account flag. An empty value is ignored +// in favor of the GH_ACCOUNT environment variable. +func SetOverride(v string) { + flagOverride = v +} + +// Override returns the explicit account override from the --account flag, +// falling back to the GH_ACCOUNT environment variable. +func Override() string { + if flagOverride != "" { + return flagOverride + } + return os.Getenv("GH_ACCOUNT") +} + +// ResolveContext carries the local, API-free context used to select an account. +type ResolveContext struct { + // Host is the host of the command being executed. It is used as the default + // host for account values that do not specify one. + Host string + // Owner is the base repository owner (organization or user login), or "" if + // it could not be determined without an API call (e.g. not in a repo). + Owner string + // Cwd is the current working directory, or "" if it could not be determined. + Cwd string + // Override is an explicit account selection from --account or GH_ACCOUNT. + // When set it takes precedence over all configured rules. + Override string +} + +// Account is a resolved account selection. +type Account struct { + User string + Host string + // Reason is a short, human-readable explanation of why this account was + // selected, suitable for debug output and `gh auth status`. + Reason string +} + +// Resolve applies account selection precedence and returns the selected account +// along with matched=true. When nothing applies it returns matched=false, and +// the caller should fall back to the globally active account. +// +// Precedence (highest first): +// 1. Override (--account / GH_ACCOUNT) +// 2. Owner rule (exact, case-insensitive match on ctx.Owner) +// 3. GitDir rule (longest matching directory prefix of ctx.Cwd) +func Resolve(rules gh.AccountRules, ctx ResolveContext) (Account, bool) { + if ctx.Override != "" { + acct := parseAccount(ctx.Override, ctx.Host) + acct.Reason = "explicit override" + return acct, true + } + + if ctx.Owner != "" { + if v, ok := rules.Owner[strings.ToLower(ctx.Owner)]; ok { + acct := parseAccount(v, ctx.Host) + acct.Reason = "owner rule for " + ctx.Owner + return acct, true + } + } + + if ctx.Cwd != "" && len(rules.GitDir) > 0 { + if prefix, v, ok := longestPrefixMatch(rules.GitDir, ctx.Cwd); ok { + acct := parseAccount(v, ctx.Host) + acct.Reason = "gitdir rule for " + prefix + return acct, true + } + } + + return Account{}, false +} + +// parseAccount splits an account value of the form "user" or "user@host". When +// the host is omitted it defaults to defaultHost. +func parseAccount(value, defaultHost string) Account { + value = strings.TrimSpace(value) + if i := strings.LastIndex(value, "@"); i >= 0 { + return Account{User: value[:i], Host: value[i+1:]} + } + return Account{User: value, Host: defaultHost} +} + +// longestPrefixMatch returns the rule whose (tilde-expanded) directory prefix is +// the longest prefix of dir. Matching is done on cleaned, absolute paths so that +// "~/work" matches "/home/u/work/repo" but not "/home/u/workshop". +func longestPrefixMatch(rules map[string]string, dir string) (prefix, value string, ok bool) { + cleanedDir := filepath.Clean(dir) + + var bestLen int + for rawPrefix, v := range rules { + p := filepath.Clean(expandTilde(rawPrefix)) + if !pathHasPrefix(cleanedDir, p) { + continue + } + if len(p) > bestLen { + bestLen = len(p) + prefix = rawPrefix + value = v + ok = true + } + } + return prefix, value, ok +} + +// pathHasPrefix reports whether dir is equal to, or nested under, prefix. +func pathHasPrefix(dir, prefix string) bool { + if dir == prefix { + return true + } + if prefix == string(filepath.Separator) { + return strings.HasPrefix(dir, prefix) + } + return strings.HasPrefix(dir, prefix+string(filepath.Separator)) +} + +// expandTilde expands a leading "~" to the user's home directory. Directory +// prefixes in config are conventionally written with a forward slash (e.g. +// "~/work/") regardless of platform, so both "/" and the OS path separator are +// accepted after the tilde. +func expandTilde(p string) string { + if p == "~" || strings.HasPrefix(p, "~/") || strings.HasPrefix(p, "~"+string(filepath.Separator)) { + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, filepath.FromSlash(strings.TrimPrefix(p, "~"))) + } + } + return p +} diff --git a/internal/accountrules/accountrules_test.go b/internal/accountrules/accountrules_test.go new file mode 100644 index 00000000000..bebff24d9d3 --- /dev/null +++ b/internal/accountrules/accountrules_test.go @@ -0,0 +1,157 @@ +package accountrules + +import ( + "path/filepath" + "testing" + + "github.com/cli/cli/v2/internal/gh" +) + +func TestResolve(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) // Windows + + work := filepath.Join(home, "work") + personal := filepath.Join(home, "personal") + + rules := gh.AccountRules{ + GitDir: map[string]string{ + "~/work/": "octocat_acme@github.com", + "~/personal/": "octocat", + }, + Owner: map[string]string{ + "acme-corp": "octocat_acme@github.com", + "octocat": "octocat@github.com", + }, + } + + tests := []struct { + name string + rules gh.AccountRules + ctx ResolveContext + wantMatched bool + wantUser string + wantHost string + }{ + { + name: "no rules, no override", + rules: gh.AccountRules{}, + ctx: ResolveContext{Host: "github.com", Owner: "acme-corp", Cwd: work}, + wantMatched: false, + }, + { + name: "override wins over everything", + rules: rules, + ctx: ResolveContext{Host: "github.com", Owner: "octocat", Cwd: personal, Override: "someoneelse@ghe.io"}, + wantMatched: true, + wantUser: "someoneelse", + wantHost: "ghe.io", + }, + { + name: "override without host defaults to command host", + rules: rules, + ctx: ResolveContext{Host: "github.com", Override: "octocat_acme"}, + wantMatched: true, + wantUser: "octocat_acme", + wantHost: "github.com", + }, + { + name: "owner rule matches (case-insensitive) and beats gitdir", + rules: rules, + ctx: ResolveContext{Host: "github.com", Owner: "ACME-Corp", Cwd: personal}, + wantMatched: true, + wantUser: "octocat_acme", + wantHost: "github.com", + }, + { + name: "gitdir rule matches when no owner rule", + rules: rules, + ctx: ResolveContext{Host: "github.com", Owner: "unknown-org", Cwd: filepath.Join(work, "billing-api")}, + wantMatched: true, + wantUser: "octocat_acme", + wantHost: "github.com", + }, + { + name: "gitdir rule without host defaults to command host", + rules: rules, + ctx: ResolveContext{Host: "github.com", Cwd: filepath.Join(personal, "dotfiles")}, + wantMatched: true, + wantUser: "octocat", + wantHost: "github.com", + }, + { + name: "no match when owner and cwd are outside any rule", + rules: rules, + ctx: ResolveContext{Host: "github.com", Owner: "stranger", Cwd: filepath.Join(home, "elsewhere")}, + wantMatched: false, + }, + { + name: "empty owner does not match owner rules", + rules: gh.AccountRules{Owner: map[string]string{"": "ghost"}}, + ctx: ResolveContext{Host: "github.com", Owner: ""}, + wantMatched: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + acct, matched := Resolve(tt.rules, tt.ctx) + if matched != tt.wantMatched { + t.Fatalf("matched = %v, want %v", matched, tt.wantMatched) + } + if !matched { + return + } + if acct.User != tt.wantUser { + t.Errorf("user = %q, want %q", acct.User, tt.wantUser) + } + if acct.Host != tt.wantHost { + t.Errorf("host = %q, want %q", acct.Host, tt.wantHost) + } + }) + } +} + +func TestLongestPrefixWins(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + rules := gh.AccountRules{ + GitDir: map[string]string{ + "~/work/": "broad@github.com", + "~/work/acme-secure/": "narrow@github.com", + }, + } + + ctx := ResolveContext{ + Host: "github.com", + Cwd: filepath.Join(home, "work", "acme-secure", "repo"), + } + + acct, matched := Resolve(rules, ctx) + if !matched { + t.Fatal("expected a match") + } + if acct.User != "narrow" { + t.Errorf("user = %q, want %q (longest prefix should win)", acct.User, "narrow") + } +} + +func TestPrefixDoesNotMatchSiblingDir(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + rules := gh.AccountRules{ + GitDir: map[string]string{"~/work/": "octocat_acme@github.com"}, + } + + // ~/workshop must not match the ~/work/ rule. + ctx := ResolveContext{Host: "github.com", Cwd: filepath.Join(home, "workshop", "repo")} + + if _, matched := Resolve(rules, ctx); matched { + t.Error("expected no match for sibling directory ~/workshop") + } +} diff --git a/internal/config/auth_config_test.go b/internal/config/auth_config_test.go index ca5f7e584cb..f8f5655c201 100644 --- a/internal/config/auth_config_test.go +++ b/internal/config/auth_config_test.go @@ -947,3 +947,38 @@ func preMigrationLogin(c *AuthConfig, hostname, username, token, gitProtocol str } return insecureStorageUsed, ghConfig.Write(c.cfg) } + +func TestAccountRulesEmptyWhenUnset(t *testing.T) { + authCfg := newTestAuthConfig(t) + + rules := authCfg.AccountRules() + + require.True(t, rules.IsEmpty()) + require.Empty(t, rules.GitDir) + require.Empty(t, rules.Owner) +} + +func TestAccountRulesParsedFromConfig(t *testing.T) { + cfgStr := `account_rules: + gitdir: + ~/work/: octocat_acme@github.com + ~/personal/: octocat + owner: + Acme-Corp: octocat_acme@github.com + octocat: octocat@github.com +` + authCfg := &AuthConfig{cfg: ghConfig.ReadFromString(cfgStr)} + + rules := authCfg.AccountRules() + + require.False(t, rules.IsEmpty()) + require.Equal(t, map[string]string{ + "~/work/": "octocat_acme@github.com", + "~/personal/": "octocat", + }, rules.GitDir) + // Owner keys are lower-cased for case-insensitive matching. + require.Equal(t, map[string]string{ + "acme-corp": "octocat_acme@github.com", + "octocat": "octocat@github.com", + }, rules.Owner) +} diff --git a/internal/config/config.go b/internal/config/config.go index dadfa284b30..1a6d50666de 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "slices" + "strings" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/keyring" @@ -17,6 +18,9 @@ import ( // Important: some of the following configuration settings are used outside of `cli/cli`, // they are defined here to avoid `cli/cli` being changed unexpectedly. const ( + accountRulesKey = "account_rules" // context-scoped account selection rules + accountRulesGitDirKey = "gitdir" + accountRulesOwnerKey = "owner" accessibleColorsKey = "accessible_colors" // used by cli/go-gh to enable the use of customizable, accessible 4-bit colors. accessiblePrompterKey = "accessible_prompter" aliasesKey = "aliases" @@ -511,6 +515,43 @@ func (c *AuthConfig) TokenForUser(hostname, user string) (string, string, error) return "", "default", fmt.Errorf("no token found for '%s'", user) } +// AccountRules reads the context-scoped account selection rules from the +// configuration. Rules are stored as addressable maps so they fit the key-value +// config API: +// +// account_rules: +// gitdir: +// "~/work/": octocat_acme@github.com +// owner: +// acme-corp: octocat_acme@github.com +// +// A missing account_rules section yields empty maps (IsEmpty() == true), in +// which case account resolution is a no-op. +func (c *AuthConfig) AccountRules() gh.AccountRules { + rules := gh.AccountRules{ + GitDir: map[string]string{}, + Owner: map[string]string{}, + } + + if keys, err := c.cfg.Keys([]string{accountRulesKey, accountRulesGitDirKey}); err == nil { + for _, k := range keys { + if v, err := c.cfg.Get([]string{accountRulesKey, accountRulesGitDirKey, k}); err == nil && v != "" { + rules.GitDir[k] = v + } + } + } + + if keys, err := c.cfg.Keys([]string{accountRulesKey, accountRulesOwnerKey}); err == nil { + for _, k := range keys { + if v, err := c.cfg.Get([]string{accountRulesKey, accountRulesOwnerKey, k}); err == nil && v != "" { + rules.Owner[strings.ToLower(k)] = v + } + } + } + + return rules +} + func keyringServiceName(hostname string) string { return "gh:" + hostname } diff --git a/internal/gh/gh.go b/internal/gh/gh.go index 759a931f2b7..c3ddb372194 100644 --- a/internal/gh/gh.go +++ b/internal/gh/gh.go @@ -153,6 +153,10 @@ type AuthConfig interface { // TokenForUser retrieves the authentication token and its source for a specified user and hostname. TokenForUser(hostname, user string) (token string, source string, err error) + // AccountRules returns the configured context-scoped account selection rules. + // When no rules are configured the returned value reports IsEmpty() == true. + AccountRules() AccountRules + // The following methods are only for testing and that is a design smell we should consider fixing. // SetActiveToken will override any token resolution and return the given token and source for all calls to @@ -170,6 +174,29 @@ type AuthConfig interface { SetDefaultHost(host, source string) } +// AccountRules holds context-scoped account selection rules, allowing gh to +// automatically choose which authenticated account to act as based on the local +// working directory or the repository owner, rather than the single global +// active account. +// +// Both maps use the account value form "user" or "user@host". When the host is +// omitted it defaults to the host of the command being executed. +type AccountRules struct { + // GitDir maps a directory prefix (with optional leading "~") to an account. + // The rule with the longest matching prefix for the current working + // directory wins. + GitDir map[string]string + // Owner maps a repository owner (organization or user login, matched + // case-insensitively) to an account. + Owner map[string]string +} + +// IsEmpty reports whether no account rules are configured. When empty, account +// resolution is a no-op and gh behaves exactly as it does without this feature. +func (r AccountRules) IsEmpty() bool { + return len(r.GitDir) == 0 && len(r.Owner) == 0 +} + // AliasConfig defines an interface for managing command aliases. type AliasConfig interface { // Get retrieves the expansion for a specified alias. diff --git a/pkg/cmd/attestation/verify/verify_integration_test.go b/pkg/cmd/attestation/verify/verify_integration_test.go index 195313b645e..d86e946f591 100644 --- a/pkg/cmd/attestation/verify/verify_integration_test.go +++ b/pkg/cmd/attestation/verify/verify_integration_test.go @@ -37,6 +37,7 @@ func TestVerifyIntegration(t *testing.T) { "test", "", &telemetry.NoOpService{}, + nil, )() require.NoError(t, err) @@ -157,6 +158,7 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) { "test", "", &telemetry.NoOpService{}, + nil, )() require.NoError(t, err) @@ -235,6 +237,7 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) { "test", "", &telemetry.NoOpService{}, + nil, )() require.NoError(t, err) @@ -332,6 +335,7 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) { "test", "", &telemetry.NoOpService{}, + nil, )() require.NoError(t, err) diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go index 658a8d8bc79..7bbf7263e4a 100644 --- a/pkg/cmd/auth/status/status.go +++ b/pkg/cmd/auth/status/status.go @@ -5,12 +5,15 @@ import ( "fmt" "net" "net/http" + "os" "path/filepath" "slices" "strings" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + ghContext "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/internal/accountrules" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/pkg/cmd/auth/shared" @@ -121,6 +124,7 @@ type StatusOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams Config func() (gh.Config, error) + Remotes func() (ghContext.Remotes, error) Exporter cmdutil.Exporter Hostname string @@ -133,6 +137,7 @@ func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Co HttpClient: f.HttpClient, IO: f.IOStreams, Config: f.Config, + Remotes: f.Remotes, } cmd := &cobra.Command{ @@ -326,9 +331,51 @@ func statusRun(opts *StatusOptions) error { } } + writeAccountResolution(opts, authCfg, cs) + return finalErr } +// writeAccountResolution reports, in human-readable output, how context-scoped +// account rules (or an explicit override) resolve for the current working +// directory and repository. This makes automatic account switching observable +// rather than silent. It is a no-op when no rules or override are configured. +func writeAccountResolution(opts *StatusOptions, authCfg gh.AuthConfig, cs *iostreams.ColorScheme) { + rules := authCfg.AccountRules() + override := accountrules.Override() + if rules.IsEmpty() && override == "" { + return + } + + defaultHost, _ := authCfg.DefaultHost() + ctx := accountrules.ResolveContext{Host: defaultHost, Override: override} + if cwd, err := os.Getwd(); err == nil { + ctx.Cwd = cwd + } + if opts.Remotes != nil { + if remotes, err := opts.Remotes(); err == nil && len(remotes) > 0 { + ctx.Owner = remotes[0].RepoOwner() + } + } + + out := opts.IO.Out + fmt.Fprintf(out, "\n%s\n", cs.Bold("Context-scoped account resolution")) + if ctx.Cwd != "" { + fmt.Fprintf(out, " - Working directory: %s\n", ctx.Cwd) + } + if ctx.Owner != "" { + fmt.Fprintf(out, " - Repository owner: %s\n", ctx.Owner) + } + + acct, matched := accountrules.Resolve(rules, ctx) + if !matched { + fmt.Fprintf(out, " - No rule matched; commands here use the active account.\n") + return + } + fmt.Fprintf(out, " - Commands here act as: %s (%s)\n", + cs.Bold(fmt.Sprintf("%s@%s", acct.User, acct.Host)), acct.Reason) +} + func maskToken(token string) string { if idx := strings.LastIndexByte(token, '_'); idx > -1 { prefix := token[0 : idx+1] diff --git a/pkg/cmd/config/config.go b/pkg/cmd/config/config.go index 5f8242d2a3d..517cb9b1d06 100644 --- a/pkg/cmd/config/config.go +++ b/pkg/cmd/config/config.go @@ -28,6 +28,30 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command { longDoc.WriteRune('\n') } + longDoc.WriteString(strings.TrimLeft(` +Context-scoped account selection: + +The `+"`account_rules`"+` key lets gh automatically choose which authenticated account +to act as, based on the current working directory or the repository owner, instead of +the single globally active account. This is useful when multiple accounts share a host +(for example an Enterprise Managed User and a personal account on github.com). Rules are +edited directly in `+"`config.yml`"+` and take the following shape: + + account_rules: + gitdir: + ~/work/: octocat_acme@github.com # any repo under ~/work uses this account + ~/personal/: octocat@github.com + owner: + acme-corp: octocat_acme@github.com # any repo owned by acme-corp uses this account + octocat: octocat@github.com + +Account values are `+"`user`"+` or `+"`user@host`"+`. When several rules apply, an owner rule wins +over a gitdir rule, and the longest matching directory prefix wins. Resolution never +changes the globally active account. Environment tokens (GH_TOKEN etc.) take precedence +over rules, followed by the `+"`--account`"+` flag / GH_ACCOUNT, then rules, then the active +account. Run `+"`gh auth status`"+` to see which account resolves for the current directory. +`, "\n")) + cmd := &cobra.Command{ Use: "config ", Short: "Manage configuration for gh", diff --git a/pkg/cmd/factory/account_resolver.go b/pkg/cmd/factory/account_resolver.go new file mode 100644 index 00000000000..9e9ec27d834 --- /dev/null +++ b/pkg/cmd/factory/account_resolver.go @@ -0,0 +1,130 @@ +package factory + +import ( + "fmt" + "io" + "os" + "strings" + "sync" + + ghContext "github.com/cli/cli/v2/context" + "github.com/cli/cli/v2/internal/accountrules" + "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/utils" +) + +// envTokenSources are the token sources that originate from environment +// variables. Tokens from these sources always take precedence and must never be +// overridden by account rules. +var envTokenSources = map[string]bool{ + "GH_TOKEN": true, + "GITHUB_TOKEN": true, + "GH_ENTERPRISE_TOKEN": true, + "GITHUB_ENTERPRISE_TOKEN": true, +} + +func isEnvTokenSource(source string) bool { + return envTokenSources[source] +} + +// activeTokenGetter mirrors the (unexported) interface the API HTTP client +// consumes for token resolution. Both gh.AuthConfig and *accountResolver satisfy +// it, allowing the factory to swap in context-scoped resolution transparently. +type activeTokenGetter interface { + ActiveToken(hostname string) (token string, source string) +} + +// accountResolver is a tokenGetter that selects the acting account from local +// context (working directory and base repository owner) before falling back to +// the globally active account. It never mutates persistent state. +// +// Precedence for each ActiveToken call: +// 1. Environment token (GH_TOKEN, etc.) — always wins. +// 2. Explicit override (--account / GH_ACCOUNT). +// 3. Owner / gitdir rules. +// 4. Globally active account (identical to today's behavior). +type accountResolver struct { + auth gh.AuthConfig + rules gh.AccountRules + // ctxFn gathers the API-free resolution context. It is invoked at most once + // per command invocation and memoized, since gathering it may run git. + ctxFn func() accountrules.ResolveContext + logf func(format string, a ...any) + + once sync.Once + cached accountrules.ResolveContext +} + +func (r *accountResolver) resolveContext() accountrules.ResolveContext { + r.once.Do(func() { r.cached = r.ctxFn() }) + return r.cached +} + +// ActiveToken implements the tokenGetter interface consumed by the HTTP client. +func (r *accountResolver) ActiveToken(hostname string) (string, string) { + token, source := r.auth.ActiveToken(hostname) + + // Environment tokens always win; never override them. + if isEnvTokenSource(source) { + return token, source + } + + ctx := r.resolveContext() + ctx.Host = hostname + + acct, matched := accountrules.Resolve(r.rules, ctx) + if !matched { + return token, source + } + + // Only apply the rule when it targets the host of this request. + if acct.Host != "" && !strings.EqualFold(acct.Host, hostname) { + return token, source + } + + t, s, err := r.auth.TokenForUser(hostname, acct.User) + if err != nil { + r.logf("account: %s selected %s@%s but no stored token was found (%v); using active account\n", acct.Reason, acct.User, hostname, err) + return token, source + } + + r.logf("account: resolved %s@%s via %s\n", acct.User, hostname, acct.Reason) + return t, s +} + +// newAccountResolvingTokenGetter wraps auth with context-scoped account +// resolution. If no rules are configured and no override is set, it returns auth +// unchanged so behavior is byte-identical to today. +func newAccountResolvingTokenGetter(auth gh.AuthConfig, remotesFn func() (ghContext.Remotes, error), logOut io.Writer) activeTokenGetter { + rules := auth.AccountRules() + if rules.IsEmpty() && accountrules.Override() == "" { + return auth + } + + debug, _ := utils.IsDebugEnabled() + logf := func(format string, a ...any) { + if debug && logOut != nil { + fmt.Fprintf(logOut, format, a...) + } + } + + return &accountResolver{ + auth: auth, + rules: rules, + ctxFn: func() accountrules.ResolveContext { + ctx := accountrules.ResolveContext{Override: accountrules.Override()} + if cwd, err := os.Getwd(); err == nil { + ctx.Cwd = cwd + } + // Owner is resolved API-free from the base git remote. Absence of a + // repository (or remotes) simply leaves Owner empty. + if remotesFn != nil { + if remotes, err := remotesFn(); err == nil && len(remotes) > 0 { + ctx.Owner = remotes[0].RepoOwner() + } + } + return ctx + }, + logf: logf, + } +} diff --git a/pkg/cmd/factory/account_resolver_test.go b/pkg/cmd/factory/account_resolver_test.go new file mode 100644 index 00000000000..cdda0b30234 --- /dev/null +++ b/pkg/cmd/factory/account_resolver_test.go @@ -0,0 +1,152 @@ +package factory + +import ( + "testing" + + "github.com/cli/cli/v2/internal/accountrules" + "github.com/cli/cli/v2/internal/gh" +) + +// fakeAuth implements gh.AuthConfig by embedding the interface (nil) and +// overriding only the methods the account resolver exercises. Any other method +// call would panic, which is what we want in these focused tests. +type fakeAuth struct { + gh.AuthConfig + activeToken func(string) (string, string) + tokenForUser func(string, string) (string, string, error) + tokenForUserHit *bool +} + +func (f fakeAuth) ActiveToken(host string) (string, string) { return f.activeToken(host) } + +func (f fakeAuth) TokenForUser(host, user string) (string, string, error) { + if f.tokenForUserHit != nil { + *f.tokenForUserHit = true + } + return f.tokenForUser(host, user) +} + +func newResolver(auth gh.AuthConfig, rules gh.AccountRules, ctx accountrules.ResolveContext) *accountResolver { + return &accountResolver{ + auth: auth, + rules: rules, + ctxFn: func() accountrules.ResolveContext { return ctx }, + logf: func(string, ...any) {}, + } +} + +func TestAccountResolver_EnvTokenAlwaysWins(t *testing.T) { + hit := false + auth := fakeAuth{ + activeToken: func(string) (string, string) { return "env-token", "GH_TOKEN" }, + tokenForUser: func(string, string) (string, string, error) { return "should-not-be-used", "keyring", nil }, + tokenForUserHit: &hit, + } + rules := gh.AccountRules{Owner: map[string]string{"acme-corp": "octocat_acme@github.com"}} + + r := newResolver(auth, rules, accountrules.ResolveContext{Owner: "acme-corp"}) + + token, source := r.ActiveToken("github.com") + if token != "env-token" || source != "GH_TOKEN" { + t.Fatalf("got (%q, %q), want env token to win", token, source) + } + if hit { + t.Error("TokenForUser must not be called when an env token is present") + } +} + +func TestAccountResolver_OwnerRuleApplies(t *testing.T) { + auth := fakeAuth{ + activeToken: func(string) (string, string) { return "active-token", "oauth_token" }, + tokenForUser: func(host, user string) (string, string, error) { + if user != "octocat_acme" { + t.Errorf("TokenForUser called with user %q, want octocat_acme", user) + } + return "acme-token", "keyring", nil + }, + } + rules := gh.AccountRules{Owner: map[string]string{"acme-corp": "octocat_acme@github.com"}} + + r := newResolver(auth, rules, accountrules.ResolveContext{Owner: "acme-corp"}) + + token, source := r.ActiveToken("github.com") + if token != "acme-token" || source != "keyring" { + t.Fatalf("got (%q, %q), want the resolved account token", token, source) + } +} + +func TestAccountResolver_NoMatchFallsBackToActive(t *testing.T) { + auth := fakeAuth{ + activeToken: func(string) (string, string) { return "active-token", "oauth_token" }, + tokenForUser: func(string, string) (string, string, error) { return "", "", nil }, + } + rules := gh.AccountRules{Owner: map[string]string{"acme-corp": "octocat_acme@github.com"}} + + r := newResolver(auth, rules, accountrules.ResolveContext{Owner: "some-other-org"}) + + token, source := r.ActiveToken("github.com") + if token != "active-token" || source != "oauth_token" { + t.Fatalf("got (%q, %q), want fallback to active account", token, source) + } +} + +func TestAccountResolver_MissingTokenForUserFallsBack(t *testing.T) { + auth := fakeAuth{ + activeToken: func(string) (string, string) { return "active-token", "oauth_token" }, + tokenForUser: func(string, string) (string, string, error) { return "", "default", errNoToken{} }, + } + rules := gh.AccountRules{Owner: map[string]string{"acme-corp": "octocat_acme@github.com"}} + + r := newResolver(auth, rules, accountrules.ResolveContext{Owner: "acme-corp"}) + + token, source := r.ActiveToken("github.com") + if token != "active-token" || source != "oauth_token" { + t.Fatalf("got (%q, %q), want fallback when no stored token exists", token, source) + } +} + +func TestAccountResolver_HostMismatchIsSkipped(t *testing.T) { + hit := false + auth := fakeAuth{ + activeToken: func(string) (string, string) { return "active-token", "oauth_token" }, + tokenForUser: func(string, string) (string, string, error) { return "other", "keyring", nil }, + tokenForUserHit: &hit, + } + // Rule targets ghe.io, but the request is for github.com. + rules := gh.AccountRules{Owner: map[string]string{"acme-corp": "octocat_acme@ghe.io"}} + + r := newResolver(auth, rules, accountrules.ResolveContext{Owner: "acme-corp"}) + + token, _ := r.ActiveToken("github.com") + if token != "active-token" { + t.Fatalf("got %q, want active token when rule host differs from request host", token) + } + if hit { + t.Error("TokenForUser must not be called when the rule targets a different host") + } +} + +func TestNewAccountResolvingTokenGetter_PassthroughWhenUnconfigured(t *testing.T) { + t.Setenv("GH_ACCOUNT", "") + auth := fakeAuth{ + AuthConfig: stubAuthConfigWithEmptyRules{}, + activeToken: func(string) (string, string) { return "active-token", "oauth_token" }, + } + + got := newAccountResolvingTokenGetter(auth, nil, nil) + + // With no rules and no override, the original auth config is returned as-is. + if _, ok := got.(*accountResolver); ok { + t.Fatal("expected passthrough to the original auth config, got a resolver wrapper") + } +} + +// stubAuthConfigWithEmptyRules provides AccountRules() returning empty rules for +// the passthrough test, since fakeAuth embeds a nil AuthConfig. +type stubAuthConfigWithEmptyRules struct{ gh.AuthConfig } + +func (stubAuthConfigWithEmptyRules) AccountRules() gh.AccountRules { return gh.AccountRules{} } + +type errNoToken struct{} + +func (errNoToken) Error() string { return "no token found" } diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index cc10075f203..33c687e4905 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -32,7 +32,10 @@ func New(appVersion string, invokingAgent string, cfgFunc func() (gh.Config, err } f.IOStreams = ios - f.HttpClient = HttpClientFunc(cfgFunc, ios, appVersion, invokingAgent, telemetryDisabler) + // remotesForAccountFunc resolves git remotes lazily and API-free so that + // account resolution can determine the base repository owner without a token. + remotesForAccountFunc := func() (ghContext.Remotes, error) { return f.Remotes() } + f.HttpClient = HttpClientFunc(cfgFunc, ios, appVersion, invokingAgent, telemetryDisabler, remotesForAccountFunc) f.PlainHttpClient = plainHttpClientFunc(ios, appVersion, invokingAgent, telemetryDisabler) f.ExternalHttpClient = externalHttpClientFunc(ios, appVersion) f.GitClient = newGitClient(f) // Depends on IOStreams, and Executable @@ -185,14 +188,18 @@ func remotesFunc(f *cmdutil.Factory) func() (ghContext.Remotes, error) { return rr.Resolver() } -func HttpClientFunc(cfgFunc func() (gh.Config, error), ios *iostreams.IOStreams, appVersion string, invokingAgent string, telemetryDisabler ghtelemetry.Disabler) func() (*http.Client, error) { +func HttpClientFunc(cfgFunc func() (gh.Config, error), ios *iostreams.IOStreams, appVersion string, invokingAgent string, telemetryDisabler ghtelemetry.Disabler, remotesFn func() (ghContext.Remotes, error)) func() (*http.Client, error) { return func() (*http.Client, error) { cfg, err := cfgFunc() if err != nil { return nil, err } + // Wrap authentication with context-scoped account resolution. When no + // account rules or override are configured this returns the auth config + // unchanged, preserving today's behavior exactly. + tokenSource := newAccountResolvingTokenGetter(cfg.Authentication(), remotesFn, ios.ErrOut) opts := api.HTTPClientOptions{ - Config: cfg.Authentication(), + Config: tokenSource, Log: ios.ErrOut, LogColorize: ios.ColorEnabled(), AppVersion: appVersion, diff --git a/pkg/cmd/factory/default_test.go b/pkg/cmd/factory/default_test.go index 9cf34f3b0e5..e9df06c50eb 100644 --- a/pkg/cmd/factory/default_test.go +++ b/pkg/cmd/factory/default_test.go @@ -353,7 +353,7 @@ func TestSSOURL(t *testing.T) { t.Run(tt.name, func(t *testing.T) { cfg := config.NewBlankConfig() ios, _, _, stderr := iostreams.Test() - client, err := HttpClientFunc(func() (gh.Config, error) { return cfg, nil }, ios, "v1.2.3", "", &telemetry.NoOpService{})() + client, err := HttpClientFunc(func() (gh.Config, error) { return cfg, nil }, ios, "v1.2.3", "", &telemetry.NoOpService{}, nil)() require.NoError(t, err) req, err := http.NewRequest("GET", ts.URL, nil) if tt.sso != "" { diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go index 0becbf5c81b..363c9f8ecf4 100644 --- a/pkg/cmd/root/help_topic.go +++ b/pkg/cmd/root/help_topic.go @@ -54,6 +54,13 @@ var HelpTopics = []helpTopic{ authenticated with, the stored credentials will be used. Otherwise, setting %[1]sGH_TOKEN%[1]s or %[1]sGH_ENTERPRISE_TOKEN%[1]s is required, depending on the targeted host. + %[1]sGH_ACCOUNT%[1]s: select which authenticated account to act as for a single invocation, in the + form %[1]suser%[1]s or %[1]suser@host%[1]s, without changing the globally active account (%[1]sgh auth switch%[1]s). + This is the explicit escape hatch for the automatic, context-scoped account resolution + configured via the %[1]saccount_rules%[1]s config key. Environment tokens (%[1]sGH_TOKEN%[1]s etc.) still take + precedence over %[1]sGH_ACCOUNT%[1]s. The %[1]s--account%[1]s global flag overrides this variable. See the + %[1]saccount_rules%[1]s section in %[1]sgh help config%[1]s for automatic, per-directory and per-owner selection. + %[1]sGH_REPO%[1]s: specify the GitHub repository in the %[1]s[HOST/]OWNER/REPO%[1]s format for commands that otherwise operate on a local repository. diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index df90bb249f9..402b0e7b446 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/accountrules" "github.com/cli/cli/v2/internal/gh/ghtelemetry" accessibilityCmd "github.com/cli/cli/v2/pkg/cmd/accessibility" actionsCmd "github.com/cli/cli/v2/pkg/cmd/actions" @@ -81,6 +82,12 @@ func NewCmdRoot(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, versi "versionInfo": versionCmd.Format(version, buildDate), }, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // Honor the global --account override for context-scoped account + // resolution. GH_ACCOUNT is used as a fallback when the flag is unset. + if account, err := cmd.Flags().GetString("account"); err == nil && account != "" { + accountrules.SetOverride(account) + } + // require that the user is authenticated before running most commands if cmdutil.IsAuthCheckEnabled(cmd) && !cmdutil.CheckAuth(cfg) { parent := cmd.Parent() @@ -100,6 +107,7 @@ func NewCmdRoot(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, versi // cmd.SetErr(f.IOStreams.ErrOut) // just let it default to os.Stderr instead cmd.PersistentFlags().Bool("help", false, "Show help for command") + cmd.PersistentFlags().String("account", "", "Act as a specific authenticated account (`user` or `user@host`) for this invocation") // override Cobra's default behaviors unless an opt-out has been set if os.Getenv("GH_COBRA") == "" {