diff --git a/README.md b/README.md index 3148b3c..8b314eb 100644 --- a/README.md +++ b/README.md @@ -96,27 +96,61 @@ nullify findings nullify auth login ``` -Opens your browser to sign in through nullify.ai with your identity provider (for example Microsoft Entra ID or GitLab). After sign-in, the CLI selects the installation that holds your data: it uses the only one automatically, asks you to pick one in a terminal, or takes `--installation ` in scripts. Tokens are stored locally and refreshed automatically. +Opens your browser to sign in with your identity provider (for example Microsoft Entra ID or GitLab). Tokens are stored locally and refreshed automatically. -If you sign in through nullify.ai, do not set `--host` or `NULLIFY_HOST` to your instance; run `nullify auth login` and the CLI routes data requests to the selected installation. An explicit instance host uses only the credentials stored for that host. - -To sign in to your own Nullify instance instead, pass its host: +With no host saved, the CLI signs in through nullify.ai, the Nullify router. With a host saved or passed, it first asks that instance where its login runs (`GET /auth/cli/config`): an instance on the router hands the login to the router, and an instance that signs in itself keeps its own login. The CLI only follows an answer naming your instance or a Nullify router, and fails otherwise. ```sh +# Through the router, or wherever your saved instance says +nullify auth login + +# Name your instance; on the router this still signs in at the router nullify auth login --host acme.nullify.ai + +# Choose the organization to start with +nullify auth login --org acme-web ``` -Switch to another installation after signing in through nullify.ai: +One router login covers every instance the router lists for you, so `--host ` works afterwards without another login, and an older login an instance ran itself is replaced. After signing in, the CLI selects the organization commands run as: the one you pass with `--org`, the one you used last, the only one, or the one you pick in a terminal. + +### Organizations + +One login can cover several organizations, on one instance or several. Each command runs as the first of these that applies: + +1. `--org ` +2. `NULLIFY_ORG` (name or id) +3. `NULLIFY_GITHUB_OWNER_ID` +4. The owner of the current directory's git remote, if it names exactly one of your organizations on the instance (worktrees and submodules included) +5. Your default, set by `nullify org use` or the last login +6. Your only organization on the instance + +If your default is no longer available, the command stops and says so rather than running as another organization; in a terminal it asks. When nothing applies and you have several organizations, the CLI asks in a terminal and remembers the answer; elsewhere it fails and lists the choices. `nullify org list` shows your organizations and which rule picked the one in use. + +Signed in through the router, `nullify org use` can name an organization on another instance: plain commands then run on that instance. `--org` does the same for one command when the command's host is the router (`nullify.ai`, the default after a router login). ```sh -nullify auth switch --installation other-org +# See your organizations on this instance and which one is in use +nullify org list + +# Make one your default +nullify org use acme-web + +# Run one command as another +nullify findings --org acme-payments ``` +An API token (`NULLIFY_TOKEN`) belongs to one organization, and the server uses it when the command names none. With a token, only `--org`, `NULLIFY_ORG` and `NULLIFY_GITHUB_OWNER_ID` apply (the first two are looked up on the instance itself); `nullify org use`, the git remote and the prompt are for browser logins. An API token always needs your instance as the host (`--host` or `NULLIFY_HOST`), never `nullify.ai`. When `NULLIFY_TOKEN` is set it wins over your browser login, and `nullify auth status` says so. + +### Upgrading from an earlier version + +Your login and your selected organization carry over. If you used an API token as a workaround for the router login, remove `NULLIFY_TOKEN` and `NULLIFY_GITHUB_OWNER_ID` from your shell and your MCP configuration, run `nullify auth login`, and restart your MCP servers. Upgrade every copy of the CLI your tools run: an older copy running alongside can overwrite the login file. + ### Environment Variables ```sh export NULLIFY_HOST=api.acme.nullify.ai export NULLIFY_TOKEN=your-token-here +export NULLIFY_ORG=acme-web # optional: organization to run as, by name or id ``` ### Multiple Instances @@ -147,10 +181,12 @@ nullify auth switch |---------|-------------| | `nullify auth login` | Authenticate via browser SSO | | `nullify auth logout` | Clear stored credentials for a host | -| `nullify auth status` | Show auth state, host, and token expiry | +| `nullify auth status` | Show auth state, host, auth host, organization, and token expiry | | `nullify auth token` | Print raw access token to stdout (pipe-friendly) | -| `nullify auth switch` | Switch active instance or installation, or list configured instances | -| `nullify auth config` | Print current CLI config as JSON | +| `nullify auth switch` | Switch active instance or list configured instances | +| `nullify auth config` | Print the effective host, auth host, data host, and organization as JSON | +| `nullify org list` | List the organizations you can use, and which one commands run as and why | +| `nullify org use ` | Remember the organization commands run as (and, through the Nullify router, its instance) | ### Scanning @@ -288,6 +324,29 @@ Add the following to your MCP config file: } ``` +One server serves one instance. When your login covers several organizations there, every tool takes an optional `org` argument (name or id), and the `list_organizations` tool lists them; every result says which organization it ran as. Without the argument a call uses the default the server settled when it started (`--org`, then the same order as the CLI); `nullify org use` in a terminal does not change a running server. If that leaves several organizations, the call returns the choices instead of guessing. To give each organization its own server, add one entry per organization: + +```json +{ + "mcpServers": { + "nullify-acme-web": { + "command": "nullify", + "args": ["mcp", "serve", "--org", "acme-web"] + }, + "nullify-acme-payments": { + "command": "nullify", + "args": ["mcp", "serve", "--org", "acme-payments"] + } + } +} +``` + +With Claude Code: + +```sh +claude mcp add nullify-acme-web -- nullify mcp serve --org acme-web +``` + Scope findings to a specific repository with `--repo`: ```json @@ -303,19 +362,28 @@ Scope findings to a specific repository with `--repo`: ## Configuration -The CLI stores configuration at `~/.nullify/config.json`. Host resolution priority: +The CLI stores configuration in `~/.nullify/`: + +| File | Holds | +|------|-------| +| `config.json` | The active instance | +| `credentials.json` | Login tokens, one entry per login host (the router's login is shared by the instances that use it) | +| `hosts.json` | Per instance: the host its login runs on, and the organization picked with `nullify org use` | + +Host resolution priority: 1. `--host` flag 2. `NULLIFY_HOST` environment variable 3. Config file (`~/.nullify/config.json`) -When the host is `nullify.ai`, the CLI signs in and refreshes tokens on nullify.ai and sends data requests to the selected installation's instance. GitHub Actions token exchange (`--github-token`) needs your instance host, not `nullify.ai`. +When the host is `nullify.ai`, the CLI signs in and refreshes tokens on nullify.ai and sends data requests to the selected organization's instance. GitHub Actions token exchange (`--github-token`) needs your instance host, not `nullify.ai`. ### Global Flags | Flag | Description | Default | |------|-------------|---------| | `--host` | Nullify instance (e.g., `acme.nullify.ai`), or `nullify.ai` to sign in through the Nullify router | From config | +| `--org` | Organization to run as, by name or id (see [Organizations](#organizations)) | | | `--output, -o` | Output format (`json`, `table`, `yaml`) | `json` | | `--verbose, -v` | Enable verbose logging | `false` | | `--debug, -d` | Enable debug logging | `false` | diff --git a/cmd/cli/cmd/auth.go b/cmd/cli/cmd/auth.go index 05a1e4f..894f984 100644 --- a/cmd/cli/cmd/auth.go +++ b/cmd/cli/cmd/auth.go @@ -1,12 +1,12 @@ package cmd import ( + "context" "encoding/json" + "errors" "fmt" - "maps" "os" "os/signal" - "slices" "syscall" "time" @@ -24,7 +24,10 @@ var authCmd = &cobra.Command{ var loginCmd = &cobra.Command{ Use: "login", Short: "Log in to Nullify", - Long: "Authenticate with Nullify. Opens your browser to log in with your identity provider. Without --host or a configured host, signs in through nullify.ai and selects an installation.", + Long: "Authenticate with Nullify. Opens your browser to log in with your identity provider. An instance that " + + "signs in through the Nullify router hands the login to the router, and one router login then covers every " + + "instance the router lists for you. Without --host, logs in where your configured instance says, or through " + + "nullify.ai. --org chooses the organization to start with.", RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) @@ -35,12 +38,12 @@ var loginCmd = &cobra.Command{ return err } - installation, _ := cmd.Flags().GetString("installation") - if installation != "" && !auth.IsRouterHost(sanitizedHost) { - return fmt.Errorf("--installation applies only when signing in through nullify.ai") + name := orgFlag + if installation, _ := cmd.Flags().GetString("installation"); name == "" { + name = installation } - if err := auth.Login(ctx, sanitizedHost, installationPicker(installation)); err != nil { + if err := auth.Login(ctx, sanitizedHost, installationPicker(name)); err != nil { return fmt.Errorf("login failed: %w", err) } return nil @@ -79,18 +82,26 @@ func installationPicker(name string) auth.InstallationPicker { var logoutCmd = &cobra.Command{ Use: "logout", Short: "Log out of Nullify", - Long: "Clear stored credentials for the current or specified host.", + Long: "Clear stored credentials for the current or specified host. On an instance that signs in through the " + + "Nullify router, this removes the router login, which every instance signed in through it shares.", RunE: func(cmd *cobra.Command, args []string) error { logoutHost, err := resolveHostForAuth() if err != nil { return err } + authHost, err := auth.AuthHostFor(logoutHost) + if err != nil { + return fmt.Errorf("logout failed: %w", err) + } if err := auth.Logout(logoutHost); err != nil { return fmt.Errorf("logout failed: %w", err) } fmt.Printf("Logged out from %s\n", logoutHost) + if auth.CredentialKey(authHost) != auth.CredentialKey(logoutHost) { + fmt.Printf("This removed the %s login, which other tenants signed in through it share.\n", authHost) + } return nil }, } @@ -98,34 +109,49 @@ var logoutCmd = &cobra.Command{ var statusCmd = &cobra.Command{ Use: "status", Short: "Show authentication status", - Long: "Display the current authentication state including host, user, and token expiry.", + Long: "Display the current authentication state including host, auth host, data host, organization, and token expiry.", RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := auth.LoadConfig() - if err != nil { - fmt.Println("Not configured. Run 'nullify auth login' to get started.") + ctx := cmd.Context() + + if usesMachineToken() { + statusHost, err := resolveHostE(ctx) + if err != nil { + if !errors.Is(err, errNoHost) { + return err + } + fmt.Println("No host configured. Pass --host or set NULLIFY_HOST.") + return nil + } + fmt.Printf("Host: %s\n", statusHost) + fmt.Println("Auth: API token") + if _, _, ok, err := auth.LookupCredentials(statusHost); err == nil && ok { + fmt.Println("Note: the API token (--nullify-token or NULLIFY_TOKEN) overrides your 'nullify auth login' session") + } + fmt.Printf("Org: %s\n", describeOrg(ctx, statusHost)) return nil } - if cfg.Host == "" { + statusHost, err := resolveHostE(ctx) + if err != nil { + if !errors.Is(err, errNoHost) { + return err + } fmt.Println("No host configured. Run 'nullify auth login'") return nil } - fmt.Printf("Host: %s\n", cfg.Host) + fmt.Printf("Host: %s\n", statusHost) - creds, err := auth.LoadCredentials() - if err != nil { - fmt.Println("Status: not authenticated") - return nil + hostCreds, authHost, ok, err := auth.LookupCredentials(statusHost) + if authHost != "" { + fmt.Printf("Auth host: %s\n", authHost) } - - hostCreds, ok := creds[auth.CredentialKey(cfg.Host)] - if !ok { + if err != nil || !ok { fmt.Println("Status: not authenticated") return nil } - if auth.IsRouterHost(cfg.Host) { + if auth.IsRouterHost(statusHost) { printInstallation(hostCreds) } @@ -139,16 +165,20 @@ var statusCmd = &cobra.Command{ } else { fmt.Println("Status: authenticated") } + if id := os.Getenv("NULLIFY_GITHUB_OWNER_ID"); id != "" { + fmt.Printf("Note: NULLIFY_GITHUB_OWNER_ID=%s chooses the organization\n", id) + } + fmt.Printf("Org: %s\n", describeOrg(ctx, statusHost)) return nil }, } func printInstallation(hostCreds auth.HostCredentials) { if hostCreds.DataHost == "" { - fmt.Println("Installation: none selected - run 'nullify auth switch --installation '") + fmt.Println("Data host: none selected - run 'nullify auth login'") return } - fmt.Printf("Installation: %s (%s)\n", hostCreds.Installation, hostCreds.DataHost) + fmt.Printf("Data host: %s (%s)\n", hostCreds.DataHost, hostCreds.Installation) } var tokenCmd = &cobra.Command{ @@ -175,24 +205,24 @@ var tokenCmd = &cobra.Command{ var switchCmd = &cobra.Command{ Use: "switch", - Short: "Switch between configured hosts or installations", - Long: "Switch the default host when multiple Nullify instances are configured, or select another installation with --installation when signed in through nullify.ai.", + Short: "Switch between configured hosts", + Long: "Switch the default host when multiple Nullify instances are configured. To choose an organization, use 'nullify org use'.", RunE: func(cmd *cobra.Command, args []string) error { switchHost, _ := cmd.Flags().GetString("host") if switchHost == "" && host != "" { switchHost = host } - installation, _ := cmd.Flags().GetString("installation") - if installation != "" { - if switchHost != "" && !auth.IsRouterHost(switchHost) { - return fmt.Errorf("--installation applies only to nullify.ai") + if installation, _ := cmd.Flags().GetString("installation"); installation != "" { + router, err := switchRouter(switchHost) + if err != nil { + return err } - install, err := auth.SwitchInstallation(cmd.Context(), installation) + org, err := auth.SwitchInstallation(cmd.Context(), router, installation) if err != nil { return fmt.Errorf("switch installation failed: %w", err) } - fmt.Printf("Switched to installation %s (%s)\n", install.Name, install.DataHost) + fmt.Printf("Using %s on %s\n", org, org.DataHost()) return nil } @@ -221,23 +251,48 @@ var switchCmd = &cobra.Command{ }, } +// switchRouter is the router 'auth switch --installation' selects on: the +// one named, else the configured host's, else the production router. +func switchRouter(named string) (auth.Router, error) { + if named != "" { + router, ok := auth.RouterFor(named) + if !ok { + return auth.Router{}, fmt.Errorf("--installation applies to a Nullify router host; use 'nullify org use' for %s", named) + } + return router, nil + } + if cfg, err := auth.LoadConfig(); err == nil { + if router, ok := auth.RouterFor(cfg.Host); ok { + return router, nil + } + if authHost, err := auth.AuthHostFor(cfg.Host); err == nil { + if router, ok := auth.RouterFor(authHost); ok { + return router, nil + } + } + } + router, _ := auth.RouterFor(auth.RouterHost) + return router, nil +} + func listConfiguredHosts() { - creds, err := auth.LoadCredentials() - if err != nil || len(creds) == 0 { + known, err := auth.KnownHosts() + if err != nil || len(known) == 0 { fmt.Println("No configured hosts. Run 'nullify auth login'") return } + creds, _ := auth.LoadCredentials() cfg, _ := auth.LoadConfig() fmt.Println("Configured hosts:") - for _, h := range slices.Sorted(maps.Keys(creds)) { + for _, h := range known { marker := " " if cfg != nil && auth.CredentialKey(cfg.Host) == h { marker = "* " } fmt.Printf("%s%s%s\n", marker, h, installationLabel(creds[h])) } - fmt.Println("\nUse 'nullify auth switch --host ' to switch hosts, or 'nullify auth switch --installation ' to switch installations on nullify.ai.") + fmt.Println("\nUse 'nullify auth switch --host ' to switch hosts, or 'nullify org use ' to choose an organization.") } func installationLabel(hostCreds auth.HostCredentials) string { @@ -250,20 +305,62 @@ func installationLabel(hostCreds auth.HostCredentials) string { var configShowCmd = &cobra.Command{ Use: "config", Short: "Show current configuration", - Long: "Display the current CLI configuration as JSON.", + Long: "Display the effective host, how it authenticates (login and its auth host, or API token), and the organization commands run as, as JSON.", RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := auth.LoadConfig() + ctx := cmd.Context() + configHost, err := resolveHostE(ctx) if err != nil { + if !errors.Is(err, errNoHost) { + return err + } fmt.Fprintf(os.Stderr, "No config found. Run 'nullify init' to set up.\n") return nil } - data, _ := json.MarshalIndent(cfg, "", " ") + shown := map[string]any{"host": configHost} + if usesMachineToken() { + shown["auth"] = "api_token" + } else { + shown["auth"] = "login" + if hosts, err := commandHostsFor(ctx, configHost); err == nil { + shown["auth_host"] = hosts.AuthHost + shown["data_host"] = hosts.DataHost + } + } + shown["org"] = describeOrg(ctx, configHost) + + data, _ := json.MarshalIndent(shown, "", " ") fmt.Println(string(data)) return nil }, } +// describeOrg says which org commands on host run as right now, where, and +// why, without prompting. +func describeOrg(ctx context.Context, host string) string { + hosts, err := commandHostsFor(ctx, host) + if err != nil { + return "unknown: " + err.Error() + } + token, err := lib.GetNullifyToken(ctx, hosts.AuthHost, nullifyToken, githubToken) + if err != nil { + return "unknown (not authenticated)" + } + in := orgInputsFor(hosts.DataHost, orgFlag, "--org") + in.AnyHost = hosts.OnRouter + scope, err := resolveOrgScope(ctx, hosts.DataHost, token, in, false) + switch { + case err != nil: + return "not chosen: " + err.Error() + case scope.Org != nil: + return fmt.Sprintf("%s on %s (%s)", scope.Org, scope.Org.DataHost(), scope.Reason) + case scope.Query["githubOwnerId"] != "": + return "githubOwnerId " + scope.Query["githubOwnerId"] + " (NULLIFY_GITHUB_OWNER_ID)" + default: + return "the API token's own (the server derives it)" + } +} + func init() { rootCmd.AddCommand(authCmd) authCmd.AddCommand(loginCmd) @@ -273,8 +370,10 @@ func init() { authCmd.AddCommand(switchCmd) authCmd.AddCommand(configShowCmd) - loginCmd.Flags().String("installation", "", "Installation to use when signing in through nullify.ai") - switchCmd.Flags().String("installation", "", "Installation to select for the nullify.ai sign-in") + loginCmd.Flags().String("installation", "", "Same as --org") + switchCmd.Flags().String("installation", "", "Same as 'nullify org use '") + _ = loginCmd.Flags().MarkHidden("installation") + _ = switchCmd.Flags().MarkHidden("installation") } func resolveHostForAuth() (string, error) { diff --git a/cmd/cli/cmd/mcp.go b/cmd/cli/cmd/mcp.go index 55e5a6f..97610a5 100644 --- a/cmd/cli/cmd/mcp.go +++ b/cmd/cli/cmd/mcp.go @@ -1,11 +1,19 @@ package cmd import ( + "bytes" "context" + "errors" "fmt" + "io" + "net/http" + "slices" "strings" + "sync" + "time" "github.com/nullify-platform/cli/internal/api" + "github.com/nullify-platform/cli/internal/auth" "github.com/nullify-platform/cli/internal/client" "github.com/nullify-platform/cli/internal/lib" "github.com/nullify-platform/cli/internal/mcp" @@ -21,50 +29,266 @@ var mcpCmd = &cobra.Command{ var mcpServeCmd = &cobra.Command{ Use: "serve", Short: "Start the MCP server", - Long: "Start the Nullify MCP server over stdio. Configure your AI tool to run 'nullify mcp serve'.", + Long: "Start the Nullify MCP server over stdio. Configure your AI tool to run 'nullify mcp serve'. " + + "When your login covers several organizations on this instance, tools take an optional org argument. " + + "The default for calls that name none is fixed when the server starts: --org, NULLIFY_ORG, " + + "NULLIFY_GITHUB_OWNER_ID, the git remote's owner, then your default ('nullify org use'). One server serves one instance.", RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - authCtx, err := resolveCommandAuth(ctx) + repoFlag, _ := cmd.Flags().GetString("repo") + toolsFlag, _ := cmd.Flags().GetString("tools") + apiClient, toolSet, orgs, err := mcpServeConfig(ctx, repoFlag, toolsFlag) if err != nil { return err } - queryParams := authCtx.QueryParams - - // Apply --repo flag or auto-detect from git - repoFlag, _ := cmd.Flags().GetString("repo") - repo := resolveRepo(repoFlag) - if repo != "" { - queryParams["repository"] = repo + if err := mcp.ServeWithClient(ctx, apiClient, toolSet, orgs); err != nil { + return fmt.Errorf("MCP server error: %w", err) } + return nil + }, +} - // Resolve --tools flag - toolsFlag, _ := cmd.Flags().GetString("tools") - toolSet := mcp.ToolSet(toolsFlag) - validSets := mcp.ValidToolSets() - validSet := false - for _, v := range validSets { - if v == toolsFlag { - validSet = true - break - } - } - if !validSet { - return fmt.Errorf("invalid --tools value %q. Valid values: %s", toolsFlag, strings.Join(validSets, ", ")) - } +// mcpOrgCacheTTL is how long the server reuses its org list: every call +// needs it, and the router can be an ocean away from the data host. +var mcpOrgCacheTTL = 3 * time.Minute + +type mcpOrgCache struct { + mu sync.Mutex + list auth.OrgList + at time.Time + fetch func(ctx context.Context) (auth.OrgList, error) +} + +func (c *mcpOrgCache) get(ctx context.Context, fresh bool) (auth.OrgList, error) { + c.mu.Lock() + defer c.mu.Unlock() + if !fresh && !c.at.IsZero() && time.Since(c.at) < mcpOrgCacheTTL { + return c.list, nil + } + list, err := c.fetch(ctx) + if err != nil { + return auth.OrgList{}, err + } + c.list, c.at = list, time.Now() + return list, nil +} - apiClient, err := newMCPAPIClient(ctx, authCtx, queryParams) +func (c *mcpOrgCache) invalidate() { + c.mu.Lock() + defer c.mu.Unlock() + c.at = time.Time{} +} + +// mcpDefault is the org a call naming none runs as, settled once from the +// inputs the server started with, so 'nullify org use' in a terminal never +// changes a running server. A failure to reach the org list is not settled. +type mcpDefault struct { + mu sync.Mutex + settled bool + org *auth.Org + query map[string]string + label string + err error +} + +// mcpServeConfig resolves the hosts and token, never an org: a login may +// cover several orgs, and each tool call picks one (its org argument, else +// the default the server started with). +func mcpServeConfig(ctx context.Context, repoFlag, toolsFlag string) (*api.Client, mcp.ToolSet, mcp.Orgs, error) { + authCtx, err := resolveCommandToken(ctx) + if err != nil { + return nil, "", mcp.Orgs{}, err + } + dataHost := authCtx.DataHost + + queryParams := map[string]string{} + if repo := resolveRepo(repoFlag); repo != "" { + queryParams["repository"] = repo + } + + toolSet := mcp.ToolSet(toolsFlag) + if !slices.Contains(mcp.ValidToolSets(), toolsFlag) { + return nil, "", mcp.Orgs{}, fmt.Errorf("invalid --tools value %q. Valid values: %s", toolsFlag, strings.Join(mcp.ValidToolSets(), ", ")) + } + + tokenProvider, refreshProvider := mcpTokenProviders(ctx, authCtx.AuthHost) + + listHost := dataHost + if !usesMachineToken() { + if listHost, err = auth.AuthHostFor(dataHost); err != nil { + return nil, "", mcp.Orgs{}, err + } + } + cache := &mcpOrgCache{fetch: func(ctx context.Context) (auth.OrgList, error) { + token, err := tokenProvider() if err != nil { - return fmt.Errorf("failed to create client: %w", err) + return auth.OrgList{}, err } + list, _, err := listOrgsFresh(ctx, dataHost, listHost, token) + return list, err + }} + + // Drive the generated API client through a refreshing+retrying transport + // so long-running MCP sessions keep working as the token rotates; the + // token is refreshed wherever the login lives (the router, for a router + // tenant). Each call's org scope is set by ScopeTransport. + refreshing, err := client.NewRefreshingHTTPClient(dataHost, tokenProvider, refreshProvider) + if err != nil { + return nil, "", mcp.Orgs{}, fmt.Errorf("failed to create client: %w", err) + } + httpClient := &http.Client{ + Timeout: refreshing.Timeout, + Transport: mcp.ScopeTransport(notHostedTransport{next: refreshing.Transport, refused: cache.invalidate}), + CheckRedirect: refreshing.CheckRedirect, + } + // Empty token literal is intentional: the refreshing transport's + // RoundTrip injects (and rotates) the Authorization header after + // api.Client.do sets it, so the static value in api.Client is + // overridden on every request. + apiClient := api.NewClient(dataHost, "", queryParams, api.WithHTTPClient(httpClient)) - err = mcp.ServeWithClient(ctx, apiClient, toolSet) + startInputs := orgInputsFor(dataHost, orgFlag, "--org") + def := &mcpDefault{} + resolveDefault := func(ctx context.Context) (*mcpDefault, error) { + def.mu.Lock() + defer def.mu.Unlock() + if def.settled { + return def, nil + } + token, err := tokenProvider() if err != nil { - return fmt.Errorf("MCP server error: %w", err) + return nil, err } - return nil - }, + scope, err := resolveOrgScope(ctx, dataHost, token, startInputs, false) + var notChosen *auth.ErrOrgNotChosen + switch { + case errors.As(err, ¬Chosen) && notChosen.Unavailable == nil: + def.err = fmt.Errorf("%s serves several of your organizations and the server started without a default; "+ + "pass the org argument (name or id), or restart the server with --org: %s", dataHost, describeChoices(notChosen.Orgs)) + case err != nil && !isOrgDecision(err): + return nil, err + case err != nil: + def.err = err + default: + def.org, def.query = scope.Org, scope.Query + def.label = scopeLabel(scope) + } + def.settled = true + return def, nil + } + + orgs := mcp.Orgs{ + Resolve: func(ctx context.Context, org string) (map[string]string, string, error) { + if org == "" { + d, err := resolveDefault(ctx) + if err != nil { + return nil, "", err + } + if d.err != nil { + return nil, "", d.err + } + return d.query, d.label, nil + } + in := auth.OrgInputs{Flag: org, FlagSource: "org argument"} + if usesMachineToken() { + token, err := tokenProvider() + if err != nil { + return nil, "", err + } + scope, err := resolveOrgScope(ctx, dataHost, token, in, false) + if err != nil { + return nil, "", err + } + return scope.Query, scopeLabel(scope), nil + } + list, err := cache.get(ctx, false) + if err != nil { + return nil, "", err + } + o, err := auth.ResolveOrg(dataHost, list, in) + if err != nil { + // The list may predate a new or moved org: look once more. + if list, err = cache.get(ctx, true); err != nil { + return nil, "", err + } + if o, err = auth.ResolveOrg(dataHost, list, in); err != nil { + return nil, "", err + } + } + return o.Query(), o.String(), nil + }, + List: func(ctx context.Context) (any, error) { + token, err := tokenProvider() + if err != nil { + return nil, err + } + out, err := orgListing(ctx, dataHost, token, startInputs) + if err != nil { + return nil, err + } + if d, err := resolveDefault(ctx); err == nil { + out["selected"], out["selection"] = nil, nil + if d.err != nil { + out["selection"] = d.err.Error() + } else { + out["selected"] = d.label + out["selection"] = "fixed when this server started" + } + } + return out, nil + }, + } + + return apiClient, toolSet, orgs, nil +} + +func scopeLabel(scope orgScope) string { + if scope.Org != nil { + return scope.Org.String() + } + if id := scope.Query["githubOwnerId"]; id != "" { + return "githubOwnerId " + id + } + return "the API token's own organization" +} + +func describeChoices(orgs []auth.Org) string { + choices := make([]string, len(orgs)) + for i, o := range orgs { + choices[i] = o.String() + } + return strings.Join(choices, ", ") +} + +// isOrgDecision reports whether err is an answer about the org (none, too +// many, not found, gone) rather than a failure to reach the org list. +func isOrgDecision(err error) bool { + var notChosen *auth.ErrOrgNotChosen + return errors.As(err, ¬Chosen) || errors.Is(err, auth.ErrNoOrgs) || errors.Is(err, auth.ErrOrgNotFound) +} + +// notHostedTransport drops the server's cached org list when the data host +// says an org is not hosted there, so the next call sees where it moved. The +// refused request is returned as is, never replayed. +type notHostedTransport struct { + next http.RoundTripper + refused func() +} + +func (t notHostedTransport) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := t.next.RoundTrip(req) + if err != nil || resp.StatusCode != http.StatusForbidden { + return resp, err + } + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) + resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(body)) + if readErr == nil && bytes.Contains(body, []byte("is not hosted on this instance")) { + t.refused() + } + return resp, nil } func init() { @@ -75,18 +299,6 @@ func init() { mcpServeCmd.Flags().String("tools", "default", "Tool set to register (default, all, minimal, findings, admin)") } -// newMCPAPIClient drives the API client through a refreshing, retrying transport so long MCP sessions survive token rotation. -func newMCPAPIClient(ctx context.Context, authCtx *commandAuthContext, queryParams map[string]string) (*api.Client, error) { - tokenProvider, refreshProvider := mcpTokenProviders(ctx, authCtx.AuthHost) - httpClient, err := client.NewRefreshingHTTPClient(authCtx.DataHost, tokenProvider, refreshProvider) - if err != nil { - return nil, err - } - - // The refreshing transport sets the Authorization header on every request, so the static token stays empty. - return api.NewClient(authCtx.DataHost, "", queryParams, api.WithHTTPClient(httpClient)), nil -} - func mcpTokenProviders(ctx context.Context, authHost string) (client.TokenProvider, client.TokenProvider) { tokenProvider := func() (string, error) { return lib.GetNullifyToken(ctx, authHost, nullifyToken, githubToken) diff --git a/cmd/cli/cmd/mcp_test.go b/cmd/cli/cmd/mcp_test.go new file mode 100644 index 0000000..1fc529a --- /dev/null +++ b/cmd/cli/cmd/mcp_test.go @@ -0,0 +1,285 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "sync" + "testing" + + "github.com/mark3labs/mcp-go/server" + "github.com/nullify-platform/cli/internal/auth" + "github.com/nullify-platform/cli/internal/logger" + "github.com/nullify-platform/cli/internal/mcp" + "github.com/stretchr/testify/require" +) + +// fakeNullify stands in for the router and the acme tenant at the transport +// level, so the real auth, refresh and client code run unchanged. +type fakeNullify struct { + mu sync.Mutex + live string // the access token the tenant accepts + refreshes []string + apiCalls []string + lists int + notHosted string // an owner id the tenant refuses as not hosted there +} + +func (f *fakeNullify) RoundTrip(r *http.Request) (*http.Response, error) { + f.mu.Lock() + defer f.mu.Unlock() + reply := func(status int, body string, header http.Header) (*http.Response, error) { + if header == nil { + header = http.Header{} + } + return &http.Response{StatusCode: status, Header: header, Body: io.NopCloser(strings.NewReader(body)), Request: r}, nil + } + + switch { + case r.URL.Host == "api.nullify.ai" && r.URL.Path == "/auth/refresh_token": + f.refreshes = append(f.refreshes, r.URL.Host) + f.live = "refreshed-token" + h := http.Header{} + h.Add("Set-Cookie", "access_token=refreshed-token; Max-Age=3600; Path=/; Secure; HttpOnly") + return reply(200, `{"expiresIn":3600}`, h) + case r.URL.Host == "api.nullify.ai" && r.URL.Path == "/admin/installations": + f.lists++ + if r.Header.Get("Authorization") != "Bearer "+f.live { + return reply(401, `{"message":"Unauthorized"}`, nil) + } + return reply(200, `{"installations": [ + {"name": "acme-web", "provider": {"id": "GitHub", "github": {}}, "nullifyBaseUrl": "https://api.acme.nullify.ai", + "queryParameters": {"githubOwnerId": "1001001"}, "provisioningStatus": "ready"}, + {"name": "acme-payments", "provider": {"id": "GitHub", "github": {}}, "nullifyBaseUrl": "https://api.acme.nullify.ai", + "queryParameters": {"githubOwnerId": "2002002"}, "provisioningStatus": "ready"}, + {"name": "beta-org", "provider": {"id": "GitHub", "github": {}}, "nullifyBaseUrl": "https://api.beta.nullify.ai", + "queryParameters": {"githubOwnerId": "3003003"}, "provisioningStatus": "ready"}]}`, nil) + case r.URL.Host == "api.acme.nullify.ai": + f.apiCalls = append(f.apiCalls, r.Header.Get("Authorization")+" "+r.URL.Query().Get("githubOwnerId")) + if r.Header.Get("Authorization") != "Bearer "+f.live { + return reply(401, `{"message":"Unauthorized"}`, nil) + } + if id := r.URL.Query().Get("githubOwnerId"); id != "" && id == f.notHosted { + return reply(403, `{"message":"Organization GitHub `+id+` is not hosted on this instance."}`, nil) + } + return reply(200, `{}`, nil) + } + return reply(404, "", nil) +} + +func TestMCPServeOnARouterLogin(t *testing.T) { + loginEnv(t) + fake := &fakeNullify{live: "router-token"} + old := http.DefaultTransport + http.DefaultTransport = fake + t.Cleanup(func() { http.DefaultTransport = old }) + + ctx, err := logger.ConfigureDevelopmentLogger(context.Background(), "error") + require.NoError(t, err) + + apiClient, toolSet, orgs, err := mcpServeConfig(ctx, "widgets", "default") + require.NoError(t, err, "serve starts with two orgs and no org chosen") + s := mcp.NewServer(apiClient, toolSet, orgs) + + call := func(args map[string]any) string { + msg, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": map[string]any{"name": "list_repositories", "arguments": args}}) + require.NoError(t, err) + out, err := json.Marshal(s.HandleMessage(ctx, msg)) + require.NoError(t, err) + return string(out) + } + + out := call(nil) + require.Contains(t, out, `"isError":true`, "two orgs and no org argument") + require.Contains(t, out, "acme-payments") + require.Contains(t, out, "pass the org argument") + + out = call(map[string]any{"org": "acme-payments"}) + require.NotContains(t, out, `"isError":true`, out) + + // The token stops being accepted mid-session, before its stored expiry: + // the API call refreshes once, at the router, never the tenant, and + // retries; the org list is still cached. + fake.mu.Lock() + fake.live = "only-a-refresh-gets-this" + fake.mu.Unlock() + out = call(map[string]any{"org": "acme-web"}) + require.NotContains(t, out, `"isError":true`, out) + + fake.mu.Lock() + defer fake.mu.Unlock() + require.Equal(t, []string{"api.nullify.ai"}, fake.refreshes) + require.Equal(t, []string{ + "Bearer router-token 2002002", + "Bearer router-token 1001001", + "Bearer refreshed-token 1001001", + }, fake.apiCalls) + + creds, err := auth.LoadCredentials() + require.NoError(t, err) + require.Equal(t, "refreshed-token", creds["nullify.ai"].AccessToken, "the refreshed router login is saved") +} + +// serve --org is the default for calls that name no org; a call's own org +// argument still wins. +func TestMCPServeOrgFlagIsTheDefault(t *testing.T) { + loginEnv(t) + fake := &fakeNullify{live: "router-token"} + old := http.DefaultTransport + http.DefaultTransport = fake + t.Cleanup(func() { http.DefaultTransport = old }) + orgFlag = "acme-payments" + + ctx, err := logger.ConfigureDevelopmentLogger(context.Background(), "error") + require.NoError(t, err) + apiClient, toolSet, orgs, err := mcpServeConfig(ctx, "", "default") + require.NoError(t, err) + s := mcp.NewServer(apiClient, toolSet, orgs) + + for _, args := range []map[string]any{nil, {"org": "acme-web"}} { + msg, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": map[string]any{"name": "list_repositories", "arguments": args}}) + require.NoError(t, err) + out, err := json.Marshal(s.HandleMessage(ctx, msg)) + require.NoError(t, err) + require.NotContains(t, string(out), `"isError":true`, string(out)) + } + + fake.mu.Lock() + defer fake.mu.Unlock() + require.Equal(t, []string{"Bearer router-token 2002002", "Bearer router-token 1001001"}, fake.apiCalls) +} + +func mcpCall(t *testing.T, ctx context.Context, s *server.MCPServer, args map[string]any) string { + t.Helper() + msg, err := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": map[string]any{"name": "list_repositories", "arguments": args}}) + require.NoError(t, err) + out, err := json.Marshal(s.HandleMessage(ctx, msg)) + require.NoError(t, err) + return string(out) +} + +func startMCP(t *testing.T, fake *fakeNullify) (context.Context, *server.MCPServer) { + t.Helper() + old := http.DefaultTransport + http.DefaultTransport = fake + t.Cleanup(func() { http.DefaultTransport = old }) + ctx, err := logger.ConfigureDevelopmentLogger(context.Background(), "error") + require.NoError(t, err) + apiClient, toolSet, orgs, err := mcpServeConfig(ctx, "", "default") + require.NoError(t, err) + return ctx, mcp.NewServer(apiClient, toolSet, orgs) +} + +// The default a server runs calls as is settled from what it started with: +// 'nullify org use' in a terminal while it runs does not move it, and every +// result names the org it ran as. +func TestMCPDefaultOrgIsFixedAtStart(t *testing.T) { + loginEnv(t) + require.NoError(t, auth.SelectOrg(testOrgA)) + fake := &fakeNullify{live: "router-token"} + ctx, s := startMCP(t, fake) + + out := mcpCall(t, ctx, s, nil) + require.Contains(t, out, "Organization: acme-web") + + require.NoError(t, auth.SelectOrg(testOrgB)) + out = mcpCall(t, ctx, s, nil) + require.Contains(t, out, "Organization: acme-web", "a terminal org use moved a running server") + out = mcpCall(t, ctx, s, map[string]any{"org": "acme-payments"}) + require.Contains(t, out, "Organization: acme-payments") + + fake.mu.Lock() + defer fake.mu.Unlock() + require.Equal(t, []string{"Bearer router-token 1001001", "Bearer router-token 1001001", "Bearer router-token 2002002"}, fake.apiCalls) +} + +// Twenty calls across both orgs at once, all rejected for an expired token: +// each keeps its own org, and the router refreshes once. +func TestMCPConcurrentCallsAcrossOrgsRefreshOnce(t *testing.T) { + loginEnv(t) + fake := &fakeNullify{live: "router-token"} + ctx, s := startMCP(t, fake) + require.NotContains(t, mcpCall(t, ctx, s, map[string]any{"org": "acme-web"}), `"isError":true`) + + fake.mu.Lock() + fake.live = "only-a-refresh-gets-this" + fake.apiCalls = nil + fake.mu.Unlock() + + var wg sync.WaitGroup + outs := make([]string, 20) + for i := range outs { + wg.Add(1) + go func() { + defer wg.Done() + org := []string{"acme-web", "acme-payments"}[i%2] + msg, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": map[string]any{"name": "list_repositories", "arguments": map[string]any{"org": org}}}) + out, err := json.Marshal(s.HandleMessage(ctx, msg)) + if err != nil { + outs[i] = "marshal error: " + err.Error() + return + } + outs[i] = string(out) + }() + } + wg.Wait() + + for i, out := range outs { + org := []string{"acme-web", "acme-payments"}[i%2] + require.NotContains(t, out, `"isError":true`, out) + require.Contains(t, out, "Organization: "+org) + } + fake.mu.Lock() + defer fake.mu.Unlock() + require.Equal(t, []string{"api.nullify.ai"}, fake.refreshes) + for _, call := range fake.apiCalls { + require.True(t, strings.HasSuffix(call, " 1001001") || strings.HasSuffix(call, " 2002002"), call) + } +} + +// A call the tenant refuses because the org is not hosted there is returned +// as is, not replayed, and the server's org list is fetched again so the +// next call sees where the org went. An org on another tenant is named with +// its host, and a name that is no org is refused. +func TestMCPNotHostedRefreshesTheOrgList(t *testing.T) { + loginEnv(t) + fake := &fakeNullify{live: "router-token", notHosted: "2002002"} + ctx, s := startMCP(t, fake) + + require.NotContains(t, mcpCall(t, ctx, s, map[string]any{"org": "acme-web"}), `"isError":true`) + require.NotContains(t, mcpCall(t, ctx, s, map[string]any{"org": "acme-web"}), `"isError":true`) + fake.mu.Lock() + lists := fake.lists + fake.mu.Unlock() + + out := mcpCall(t, ctx, s, map[string]any{"org": "acme-payments"}) + require.Contains(t, out, "not hosted on this instance") + fake.mu.Lock() + calls := 0 + for _, c := range fake.apiCalls { + if strings.HasSuffix(c, " 2002002") { + calls++ + } + } + fake.mu.Unlock() + require.Equal(t, 1, calls, "the refused call was replayed") + + require.NotContains(t, mcpCall(t, ctx, s, map[string]any{"org": "acme-web"}), `"isError":true`) + fake.mu.Lock() + require.Equal(t, lists+1, fake.lists, "the org list was not fetched again after the refusal") + fake.mu.Unlock() + + out = mcpCall(t, ctx, s, map[string]any{"org": "beta-org"}) + require.Contains(t, out, `"isError":true`) + require.Contains(t, out, "api.beta.nullify.ai") + + out = mcpCall(t, ctx, s, map[string]any{"org": "acme-wbe"}) + require.Contains(t, out, `"isError":true`) + require.Contains(t, out, "not one of your organizations") +} diff --git a/cmd/cli/cmd/org.go b/cmd/cli/cmd/org.go new file mode 100644 index 0000000..16cfdb4 --- /dev/null +++ b/cmd/cli/cmd/org.go @@ -0,0 +1,310 @@ +package cmd + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/nullify-platform/cli/internal/auth" + "github.com/nullify-platform/cli/internal/lib" + "github.com/nullify-platform/cli/internal/output" + "github.com/spf13/cobra" +) + +// listOrgs, promptIn and promptOut are vars so tests can stand in for the +// router and the terminal. +var ( + listOrgs = auth.ListOrgs + promptIn io.Reader = os.Stdin + promptOut io.Writer = os.Stderr +) + +// orgScope is the org a command runs as and the query parameters that say +// so. Org is nil when the scope is left to the server (an API token's own +// org) or given only as a raw id. +type orgScope struct { + Org *auth.Org + Query map[string]string + // Reason says which rule chose Org. + Reason string + // Token is the token the org list accepted, newer than the caller's + // when the list refused that one and it was refreshed. + Token string +} + +// usesMachineToken reports whether the command authenticates with an API or +// GitHub Actions token rather than a browser login, in lib.GetNullifyToken's +// precedence. Such a token carries its own single org. +func usesMachineToken() bool { + return nullifyToken != "" || os.Getenv("NULLIFY_TOKEN") != "" || + (os.Getenv("GITHUB_ACTIONS") == "true" && githubToken != "" && os.Getenv("GITHUB_REPOSITORY") != "") +} + +// orgInputsFor gathers every way the current invocation can name its org on +// host. flag and source are --org, or a caller's equivalent. +func orgInputsFor(host, flag, source string) auth.OrgInputs { + in := auth.OrgInputs{ + Flag: flag, + FlagSource: source, + EnvOrg: os.Getenv("NULLIFY_ORG"), + EnvGitHubOwnerID: os.Getenv("NULLIFY_GITHUB_OWNER_ID"), + GitOwner: lib.DetectOwnerFromGit(), + } + in.Stored = auth.StoredPick(host) + return in +} + +// resolveOrgScope picks the org a command on host runs as (auth.ResolveOrg). +// An API token names its org only when asked to: with no explicit choice +// the request goes without one and the server takes the token's own. When +// the choice is ambiguous, or the remembered org is gone, and prompt is set, +// the user is asked and the answer is remembered for host. +func resolveOrgScope(ctx context.Context, host, token string, in auth.OrgInputs, prompt bool) (orgScope, error) { + listHost := host + if usesMachineToken() { + if strings.TrimSpace(in.Flag) == "" && strings.TrimSpace(in.EnvOrg) == "" { + if id := strings.TrimSpace(in.EnvGitHubOwnerID); id != "" { + return orgScope{Query: map[string]string{"githubOwnerId": id}, Reason: "NULLIFY_GITHUB_OWNER_ID", Token: token}, nil + } + return orgScope{Query: map[string]string{}, Reason: "the API token's own", Token: token}, nil + } + // The token is the tenant's, so its orgs are read from the tenant. + in = auth.OrgInputs{Flag: in.Flag, FlagSource: in.FlagSource, EnvOrg: in.EnvOrg} + } else { + var err error + if listHost, err = auth.AuthHostFor(host); err != nil { + return orgScope{}, err + } + if !auth.IsRouterHost(listHost) { + in.AnyHost = false + } + } + + list, token, err := listOrgsFresh(ctx, host, listHost, token) + if err != nil { + return orgScope{}, err + } + + org, reason, err := auth.ResolveOrgReason(host, list, in) + var notChosen *auth.ErrOrgNotChosen + if errors.As(err, ¬Chosen) && prompt && !usesMachineToken() { + explicit := strings.TrimSpace(in.Flag) != "" || strings.TrimSpace(in.EnvOrg) != "" || strings.TrimSpace(in.EnvGitHubOwnerID) != "" + if notChosen.Unavailable != nil { + fmt.Fprintf(promptOut, "Your default organization %s is no longer available on %s.\n", notChosen.Unavailable.Name, host) + } + org, err = promptForOrg(host, notChosen.Orgs) + reason = "chosen at the prompt" + // An ambiguous --org answers this command only; the remembered pick + // changes when nothing named an org. + if err == nil && !explicit { + if saveErr := auth.RememberOrg(org); saveErr != nil { + fmt.Fprintf(promptOut, "Could not remember %s for %s: %v\n", org.Name, host, saveErr) + } else { + fmt.Fprintf(promptOut, "Using %s on %s from now on; change it with 'nullify org use'.\n", org.Name, host) + } + } + } + if err != nil { + return orgScope{}, err + } + return orgScope{Org: &org, Query: org.Query(), Reason: reason, Token: token}, nil +} + +// listOrgsFresh lists orgs with token, and once more with a force-refreshed +// token if the list rejects it: a token can stop being accepted before its +// stored expiry, and the org list is often a command's first call. It +// returns the token the list accepted. +func listOrgsFresh(ctx context.Context, host, listHost, token string) (auth.OrgList, string, error) { + list, err := listOrgs(ctx, host, listHost, token) + if !errors.Is(err, auth.ErrTokenRejected) { + return list, token, err + } + fresh, refreshErr := lib.RefreshNullifyToken(ctx, host, nullifyToken, githubToken) + if refreshErr != nil || fresh == "" || fresh == token { + return list, token, err + } + list, err = listOrgs(ctx, host, listHost, fresh) + return list, fresh, err +} + +func promptForOrg(host string, orgs []auth.Org) (auth.Org, error) { + fmt.Fprintf(promptOut, "%s serves several of your organizations:\n", host) + for i, o := range orgs { + fmt.Fprintf(promptOut, " %d) %s\n", i+1, o) + } + fmt.Fprint(promptOut, "Choose one (number or name): ") + + line, err := bufio.NewReader(promptIn).ReadString('\n') + if err != nil && line == "" { + return auth.Org{}, fmt.Errorf("no organization chosen: %w", err) + } + answer := strings.TrimSpace(line) + if n, convErr := strconv.Atoi(answer); convErr == nil && n >= 1 && n <= len(orgs) { + return orgs[n-1], nil + } + for _, o := range orgs { + if o.Matches(answer) { + return o, nil + } + } + return auth.Org{}, fmt.Errorf("%q is not one of the listed organizations", answer) +} + +var orgCmd = &cobra.Command{ + Use: "org", + Short: "Choose the organization commands run as", + Long: "A login can cover several organizations, and a router login several instances. Commands run as the org chosen by " + + "--org, NULLIFY_ORG, NULLIFY_GITHUB_OWNER_ID, the git remote's owner, your default ('nullify org use' or the last login), or the only org, in that order.", +} + +var orgListCmd = &cobra.Command{ + Use: "list", + Short: "List the organizations you can use on this host", + Long: "List the organizations your login can use on this host, those not ready yet, and those on " + + "other hosts. \"selected\" is the organization a command run now would use (--org changes it), and " + + "\"selection\" says why none is when that is the case.", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + authCtx, err := resolveCommandToken(ctx) + if err != nil { + return err + } + + out, err := orgListing(ctx, authCtx.DataHost, authCtx.Token, auth.OrgInputs{Flag: orgFlag, FlagSource: "--org", AnyHost: authCtx.OnRouter}) + if err != nil { + return err + } + + data, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + return output.Print(cmd, data) + }, +} + +var orgUseCmd = &cobra.Command{ + Use: "use ", + Short: "Remember the organization commands run as", + Long: "Remember the organization commands run as. Signed in through the Nullify router, it can name an " + + "organization on another instance, and plain commands then run on that instance.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + if usesMachineToken() { + return fmt.Errorf("an API token carries its own organization; 'nullify org use' applies to 'nullify auth login' sessions") + } + authCtx, err := resolveCommandToken(ctx) + if err != nil { + return err + } + + listHost, err := auth.AuthHostFor(authCtx.DataHost) + if err != nil { + return err + } + list, _, err := listOrgsFresh(ctx, authCtx.DataHost, listHost, authCtx.Token) + if err != nil { + return err + } + router, onRouter := auth.RouterFor(listHost) + org, err := auth.ResolveOrg(authCtx.DataHost, list, auth.OrgInputs{Flag: args[0], FlagSource: "org", AnyHost: onRouter}) + if err != nil { + return err + } + // An org on another tenant of the same router signs in through it. + if onRouter { + if err := auth.BindTenant(org.DataHost(), router); err != nil { + return err + } + } + if err := auth.SelectOrg(org); err != nil { + return err + } + + fmt.Printf("Using %s on %s\n", org, org.DataHost()) + if pinned := pinnedEnvHost(); pinned != "" && pinned != org.DataHost() && !auth.IsRouterHost(pinned) { + fmt.Fprintf(promptOut, "Note: NULLIFY_HOST still points commands at %s; unset it to run on %s.\n", pinned, org.DataHost()) + } + return nil + }, +} + +// pinnedEnvHost is the tenant NULLIFY_HOST pins plain commands to, in +// credential key form, or "" when it is unset. --host lasts one command and +// pins nothing, but NULLIFY_HOST still applies to the next one. +func pinnedEnvHost() string { + pinned, err := lib.SanitizeNullifyHost(os.Getenv("NULLIFY_HOST")) + if err != nil { + return "" + } + return auth.CredentialKey(pinned) +} + +// orgListing describes the orgs the token can use on host and which one a +// call naming none would run as, and why, for `nullify org list` and the MCP +// list_organizations tool. in is how the caller names its org. +func orgListing(ctx context.Context, host, token string, in auth.OrgInputs) (map[string]any, error) { + listHost := host + if !usesMachineToken() { + var err error + if listHost, err = auth.AuthHostFor(host); err != nil { + return nil, err + } + } + list, token, err := listOrgsFresh(ctx, host, listHost, token) + if err != nil { + return nil, err + } + + out := map[string]any{ + "host": host, + "organizations": nonNil(list.Orgs), + } + if len(list.Pending) > 0 { + out["pending"] = list.Pending + } + if len(list.Elsewhere) > 0 { + out["elsewhere"] = list.Elsewhere + } + full := orgInputsFor(host, in.Flag, in.FlagSource) + full.AnyHost = in.AnyHost + if in.Stored != nil { + full.Stored = in.Stored + } + scope, err := resolveOrgScope(ctx, host, token, full, false) + switch { + case err != nil: + out["selected"] = nil + out["selection"] = err.Error() + case scope.Org != nil: + out["selected"] = scope.Org.Name + out["selection"] = scope.Reason + case scope.Query["githubOwnerId"] != "": + out["selected"] = nil + out["selection"] = "githubOwnerId " + scope.Query["githubOwnerId"] + " (NULLIFY_GITHUB_OWNER_ID)" + default: + out["selected"] = nil + out["selection"] = "the API token's own organization" + } + return out, nil +} + +func nonNil(orgs []auth.Org) []auth.Org { + if orgs == nil { + return []auth.Org{} + } + return orgs +} + +func init() { + rootCmd.AddCommand(orgCmd) + orgCmd.AddCommand(orgListCmd) + orgCmd.AddCommand(orgUseCmd) +} diff --git a/cmd/cli/cmd/org_test.go b/cmd/cli/cmd/org_test.go new file mode 100644 index 0000000..63f921c --- /dev/null +++ b/cmd/cli/cmd/org_test.go @@ -0,0 +1,693 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/nullify-platform/cli/internal/auth" + "github.com/stretchr/testify/require" +) + +var ( + testOrgA = auth.Org{Name: "acme-web", Provider: "github", Key: "githubOwnerId", ID: "1001001", Host: "api.acme.nullify.ai"} + testOrgB = auth.Org{Name: "acme-payments", Provider: "github", Key: "githubOwnerId", ID: "2002002", Host: "api.acme.nullify.ai"} +) + +// stubOrgList replaces the org lister; list receives the host it was asked +// to list on. +func stubOrgList(t *testing.T, list func(listHost string) (auth.OrgList, error)) { + t.Helper() + old := listOrgs + listOrgs = func(_ context.Context, _, listHost, _ string) (auth.OrgList, error) { return list(listHost) } + t.Cleanup(func() { listOrgs = old }) +} + +// loginEnv sets up a browser login on acme bound to the router, with no +// token or org in the environment and a working directory outside any git +// repository. +func loginEnv(t *testing.T) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Chdir(t.TempDir()) + t.Setenv("NULLIFY_HOST", "acme.nullify.ai") + t.Setenv("NULLIFY_TOKEN", "") + t.Setenv("NULLIFY_ORG", "") + t.Setenv("NULLIFY_GITHUB_OWNER_ID", "") + t.Setenv("GITHUB_ACTIONS", "") + + require.NoError(t, auth.SaveHostCredentials("api.nullify.ai", auth.HostCredentials{ + AccessToken: "router-token", + RefreshToken: "rt", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + QueryParameters: map[string]string{"githubOwnerId": "1001001"}, + })) + require.NoError(t, auth.UpdateHostConfig("acme.nullify.ai", func(hc auth.HostConfig) auth.HostConfig { + hc.AuthHost = "api.nullify.ai" + return hc + })) + + oldHost, oldToken, oldGithub, oldOrg := host, nullifyToken, githubToken, orgFlag + host, nullifyToken, githubToken, orgFlag = "", "", "", "" + t.Cleanup(func() { host, nullifyToken, githubToken, orgFlag = oldHost, oldToken, oldGithub, oldOrg }) +} + +func twoTestOrgs(t *testing.T) *[]string { + listed := &[]string{} + stubOrgList(t, func(listHost string) (auth.OrgList, error) { + *listed = append(*listed, listHost) + return auth.OrgList{Orgs: []auth.Org{testOrgA, testOrgB}}, nil + }) + return listed +} + +func TestResolveCommandAuthListsOrgsOnTheRouter(t *testing.T) { + loginEnv(t) + listed := twoTestOrgs(t) + orgFlag = "acme-payments" + + authCtx, err := resolveCommandAuth(context.Background()) + require.NoError(t, err) + require.Equal(t, "router-token", authCtx.Token) + require.Equal(t, map[string]string{"githubOwnerId": "2002002"}, authCtx.QueryParams) + require.Equal(t, []string{"api.nullify.ai"}, *listed) +} + +// Two orgs, nothing chosen, no terminal: the command fails with the choices +// instead of falling back to the saved scope or the lowest id. +func TestResolveCommandAuthAmbiguousWithoutATerminal(t *testing.T) { + loginEnv(t) + twoTestOrgs(t) + + _, err := resolveCommandAuth(context.Background()) + var notChosen *auth.ErrOrgNotChosen + require.ErrorAs(t, err, ¬Chosen) + require.Contains(t, err.Error(), "acme-web") + require.Contains(t, err.Error(), "acme-payments") +} + +func TestOrgEnvVarsBeatTheStoredPick(t *testing.T) { + loginEnv(t) + twoTestOrgs(t) + require.NoError(t, auth.SelectOrg(testOrgA)) + + authCtx, err := resolveCommandAuth(context.Background()) + require.NoError(t, err) + require.Equal(t, "1001001", authCtx.QueryParams["githubOwnerId"], "stored pick applies with nothing else set") + + t.Setenv("NULLIFY_GITHUB_OWNER_ID", "2002002") + authCtx, err = resolveCommandAuth(context.Background()) + require.NoError(t, err) + require.Equal(t, "2002002", authCtx.QueryParams["githubOwnerId"], "NULLIFY_GITHUB_OWNER_ID beats the stored pick") + + t.Setenv("NULLIFY_GITHUB_OWNER_ID", "") + t.Setenv("NULLIFY_ORG", "acme-payments") + authCtx, err = resolveCommandAuth(context.Background()) + require.NoError(t, err) + require.Equal(t, "2002002", authCtx.QueryParams["githubOwnerId"], "NULLIFY_ORG beats the stored pick") + + orgFlag = "acme-web" + authCtx, err = resolveCommandAuth(context.Background()) + require.NoError(t, err) + require.Equal(t, "1001001", authCtx.QueryParams["githubOwnerId"], "--org beats NULLIFY_ORG") +} + +func TestPromptAnswerIsRemembered(t *testing.T) { + loginEnv(t) + twoTestOrgs(t) + var shown bytes.Buffer + oldIn, oldOut := promptIn, promptOut + promptIn, promptOut = strings.NewReader("2\n"), &shown + t.Cleanup(func() { promptIn, promptOut = oldIn, oldOut }) + + scope, err := resolveOrgScope(context.Background(), "acme.nullify.ai", "router-token", orgInputsFor("acme.nullify.ai", "", "--org"), true) + require.NoError(t, err) + require.Equal(t, testOrgB, *scope.Org) + require.Contains(t, shown.String(), "1) acme-web") + + hosts, err := auth.LoadHosts() + require.NoError(t, err) + require.Equal(t, &auth.OrgPick{Name: "acme-payments", Key: "githubOwnerId", ID: "2002002"}, hosts["acme.nullify.ai"].Org) + require.Equal(t, "api.nullify.ai", hosts["acme.nullify.ai"].AuthHost, "remembering the org keeps the auth host") + + // The next command, with no terminal, uses the remembered answer. + authCtx, err := resolveCommandAuth(context.Background()) + require.NoError(t, err) + require.Equal(t, "2002002", authCtx.QueryParams["githubOwnerId"]) +} + +func TestPromptRejectsAnUnlistedAnswer(t *testing.T) { + loginEnv(t) + twoTestOrgs(t) + oldIn, oldOut := promptIn, promptOut + promptIn, promptOut = strings.NewReader("someone-else\n"), &bytes.Buffer{} + t.Cleanup(func() { promptIn, promptOut = oldIn, oldOut }) + + _, err := resolveOrgScope(context.Background(), "acme.nullify.ai", "router-token", orgInputsFor("acme.nullify.ai", "", "--org"), true) + require.Error(t, err) + hosts, _ := auth.LoadHosts() + require.Nil(t, hosts["acme.nullify.ai"].Org) +} + +// A default org the user can no longer use stops the command and names it: +// running as the other org instead would act on the wrong one's data. +func TestUnavailableDefaultIsAnErrorNotAnotherOrg(t *testing.T) { + loginEnv(t) + stubOrgList(t, func(string) (auth.OrgList, error) { + return auth.OrgList{Orgs: []auth.Org{testOrgB}}, nil + }) + require.NoError(t, auth.SelectOrg(testOrgA)) + + _, err := resolveCommandAuth(context.Background()) + var notChosen *auth.ErrOrgNotChosen + require.ErrorAs(t, err, ¬Chosen) + require.Contains(t, err.Error(), "your default organization acme-web") + require.Contains(t, err.Error(), "acme-payments") +} + +func TestAPITokenOrgSelection(t *testing.T) { + loginEnv(t) + t.Setenv("NULLIFY_TOKEN", "api-token") + listed := twoTestOrgs(t) + + authCtx, err := resolveCommandAuth(context.Background()) + require.NoError(t, err) + require.Empty(t, authCtx.QueryParams, "the server takes an API token's own org") + require.Empty(t, *listed, "no org list without an explicit org") + + t.Setenv("NULLIFY_GITHUB_OWNER_ID", "999") + authCtx, err = resolveCommandAuth(context.Background()) + require.NoError(t, err) + require.Equal(t, map[string]string{"githubOwnerId": "999"}, authCtx.QueryParams) + require.Empty(t, *listed) + + t.Setenv("NULLIFY_GITHUB_OWNER_ID", "") + orgFlag = "acme-web" + authCtx, err = resolveCommandAuth(context.Background()) + require.NoError(t, err) + require.Equal(t, "1001001", authCtx.QueryParams["githubOwnerId"]) + require.Equal(t, []string{"acme.nullify.ai"}, *listed, "an API token lists on its own tenant, never the router") +} + +func TestOrgUseRemembersThePick(t *testing.T) { + loginEnv(t) + twoTestOrgs(t) + + orgUseCmd.SetContext(context.Background()) + require.NoError(t, orgUseCmd.RunE(orgUseCmd, []string{"2002002"})) + + hosts, err := auth.LoadHosts() + require.NoError(t, err) + require.Equal(t, "acme-payments", hosts["acme.nullify.ai"].Org.Name) + + err = orgUseCmd.RunE(orgUseCmd, []string{"nope"}) + require.Error(t, err) + require.Equal(t, "acme-payments", mustLoadHosts(t)["acme.nullify.ai"].Org.Name, "a failed use keeps the old pick") +} + +func TestOrgListFailureSurfaces(t *testing.T) { + loginEnv(t) + stubOrgList(t, func(string) (auth.OrgList, error) { return auth.OrgList{}, errors.New("router down") }) + + _, err := resolveCommandAuth(context.Background()) + require.ErrorContains(t, err, "router down") +} + +func mustLoadHosts(t *testing.T) auth.Hosts { + t.Helper() + hosts, err := auth.LoadHosts() + require.NoError(t, err) + return hosts +} + +func captureStdout(t *testing.T, run func()) string { + t.Helper() + r, w, err := os.Pipe() + require.NoError(t, err) + old := os.Stdout + os.Stdout = w + defer func() { os.Stdout = old }() + run() + require.NoError(t, w.Close()) + out, err := io.ReadAll(r) + require.NoError(t, err) + return string(out) +} + +func TestAuthConfigShowsHostAuthHostAndOrg(t *testing.T) { + loginEnv(t) + twoTestOrgs(t) + require.NoError(t, auth.SelectOrg(testOrgB)) + + configShowCmd.SetContext(context.Background()) + out := captureStdout(t, func() { require.NoError(t, configShowCmd.RunE(configShowCmd, nil)) }) + var shown map[string]string + require.NoError(t, json.Unmarshal([]byte(out), &shown), out) + require.Equal(t, map[string]string{ + "host": "acme.nullify.ai", + "auth": "login", + "auth_host": "api.nullify.ai", + "data_host": "acme.nullify.ai", + "org": "acme-payments (githubOwnerId 2002002) on acme.nullify.ai (your default)", + }, shown) + + t.Setenv("NULLIFY_TOKEN", "api-token") + out = captureStdout(t, func() { require.NoError(t, configShowCmd.RunE(configShowCmd, nil)) }) + shown = nil + require.NoError(t, json.Unmarshal([]byte(out), &shown), out) + require.Equal(t, "api_token", shown["auth"]) + require.Equal(t, "the API token's own (the server derives it)", shown["org"]) + require.NotContains(t, shown, "auth_host") +} + +func TestAuthStatusShowsTheOrg(t *testing.T) { + loginEnv(t) + twoTestOrgs(t) + require.NoError(t, auth.SaveConfig(&auth.Config{Host: "acme.nullify.ai"})) + + statusCmd.SetContext(context.Background()) + out := captureStdout(t, func() { require.NoError(t, statusCmd.RunE(statusCmd, nil)) }) + require.Contains(t, out, "Auth host: api.nullify.ai") + require.Contains(t, out, "Status: authenticated") + require.Contains(t, out, "Org: not chosen") + require.Contains(t, out, "acme-payments") +} + +// The saved host is only the default: status for a tenant named by +// NULLIFY_HOST or --host must describe that tenant, and a mistyped --host is +// an error rather than "no host configured". +func TestAuthStatusUsesTheEffectiveHost(t *testing.T) { + loginEnv(t) + twoTestOrgs(t) + require.NoError(t, auth.SaveConfig(&auth.Config{Host: "router.nullify.ai"})) + + statusCmd.SetContext(context.Background()) + out := captureStdout(t, func() { require.NoError(t, statusCmd.RunE(statusCmd, nil)) }) + require.Contains(t, out, "Host: acme.nullify.ai") + require.Contains(t, out, "Auth host: api.nullify.ai") + require.Contains(t, out, "acme-payments") + + host = "other.nullify.ai" + out = captureStdout(t, func() { require.NoError(t, statusCmd.RunE(statusCmd, nil)) }) + require.Contains(t, out, "Host: other.nullify.ai") + require.Contains(t, out, "Status: not authenticated") + + host = "acme.example.com" + require.ErrorContains(t, statusCmd.RunE(statusCmd, nil), "invalid host") +} + +// An --org that matches two orgs is settled at the prompt for this command +// only; it must not replace the remembered pick. +func TestAmbiguousExplicitOrgDoesNotChangeTheRememberedPick(t *testing.T) { + loginEnv(t) + dupGitHub := auth.Org{Name: "dup", Provider: "github", Key: "githubOwnerId", ID: "1", Host: "api.acme.nullify.ai"} + dupGitLab := auth.Org{Name: "dup", Provider: "gitlab", Key: "gitlabGroupId", ID: "2", Host: "api.acme.nullify.ai"} + stubOrgList(t, func(string) (auth.OrgList, error) { + return auth.OrgList{Orgs: []auth.Org{dupGitHub, dupGitLab, testOrgA}}, nil + }) + require.NoError(t, auth.SelectOrg(testOrgA)) + oldIn, oldOut := promptIn, promptOut + promptIn, promptOut = strings.NewReader("2\n"), &bytes.Buffer{} + t.Cleanup(func() { promptIn, promptOut = oldIn, oldOut }) + + scope, err := resolveOrgScope(context.Background(), "acme.nullify.ai", "router-token", orgInputsFor("acme.nullify.ai", "dup", "--org"), true) + require.NoError(t, err) + require.Equal(t, dupGitLab, *scope.Org) + require.Equal(t, "acme-web", mustLoadHosts(t)["acme.nullify.ai"].Org.Name) +} + +// A corrupt hosts.json must not break an API-token command, which never +// reads it. +func TestAPITokenIgnoresHostsFile(t *testing.T) { + loginEnv(t) + t.Setenv("NULLIFY_TOKEN", "api-token") + require.NoError(t, os.WriteFile(os.Getenv("HOME")+"/.nullify/hosts.json", []byte("{broken"), 0600)) + + authCtx, err := resolveCommandAuth(context.Background()) + require.NoError(t, err) + require.Empty(t, authCtx.QueryParams) +} + +// A host the user names is checked as the host a request would reach: a +// fragment, userinfo or port is refused before any token is read, and an +// invalid NULLIFY_HOST is an error rather than a reason to use the config. +func TestInvalidHostsAreRefusedBeforeAnyRequest(t *testing.T) { + loginEnv(t) + listed := twoTestOrgs(t) + + for _, bad := range []string{"api.attacker.example#.nullify.ai", "attacker.example@acme.nullify.ai", "acme.nullify.ai:8443"} { + host = bad + _, err := resolveCommandAuth(context.Background()) + require.ErrorContains(t, err, "invalid host", bad) + } + host = "" + + t.Setenv("NULLIFY_HOST", "api.attacker.example#.nullify.ai") + require.NoError(t, auth.SaveConfig(&auth.Config{Host: "acme.nullify.ai"})) + _, err := resolveCommandAuth(context.Background()) + require.ErrorContains(t, err, "invalid NULLIFY_HOST") + require.Empty(t, *listed, "no request went anywhere") +} + +// Upgrading from CLI v0.26.0: its router login selected globex-web on globex +// and left no hosts file. This version keeps that choice, and when it is no +// longer available says so instead of running as the other org. +func TestV026RouterLoginUpgrade(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Chdir(t.TempDir()) + for _, env := range []string{"NULLIFY_HOST", "NULLIFY_TOKEN", "NULLIFY_ORG", "NULLIFY_GITHUB_OWNER_ID", "GITHUB_ACTIONS"} { + t.Setenv(env, "") + } + resetCommandFlags(t) + oldOrg := orgFlag + orgFlag = "" + t.Cleanup(func() { orgFlag = oldOrg }) + + require.NoError(t, auth.SaveConfig(&auth.Config{Host: "nullify.ai"})) + require.NoError(t, auth.SaveHostCredentials("nullify.ai", auth.HostCredentials{ + AccessToken: "router-token", + RefreshToken: "rt", + ExpiresAt: time.Now().Add(time.Hour).Unix(), + DataHost: "globex.nullify.ai", + Installation: "globex-web", + QueryParameters: map[string]string{"githubOwnerId": "5005005"}, + })) + + opsOrg := auth.Org{Name: "globex-ops", Provider: "github", Key: "githubOwnerId", ID: "6006006", Host: "api.globex.nullify.ai"} + webOrg := auth.Org{Name: "globex-web", Provider: "github", Key: "githubOwnerId", ID: "5005005", Host: "api.globex.nullify.ai"} + orgs := []auth.Org{opsOrg, webOrg} + var listedOn []string + stubOrgList(t, func(listHost string) (auth.OrgList, error) { + listedOn = append(listedOn, listHost) + return auth.OrgList{Orgs: orgs}, nil + }) + + authCtx, err := resolveCommandAuth(context.Background()) + require.NoError(t, err) + require.Equal(t, "globex.nullify.ai", authCtx.DataHost) + require.Equal(t, map[string]string{"githubOwnerId": "5005005"}, authCtx.QueryParams) + require.Equal(t, []string{"api.nullify.ai"}, listedOn, "the org list comes from the router") + + orgFlag = "globex-ops" + authCtx, err = resolveCommandAuth(context.Background()) + require.NoError(t, err) + require.Equal(t, map[string]string{"githubOwnerId": "6006006"}, authCtx.QueryParams) + orgFlag = "" + + orgs = []auth.Org{opsOrg} + _, err = resolveCommandAuth(context.Background()) + require.ErrorContains(t, err, "your default organization globex-web") +} + +// An API token in the environment wins over a stored login; status says so, +// since a forgotten NULLIFY_TOKEN makes a fresh login look like it did nothing. +func TestAuthStatusNotesAnEnvTokenOverridingTheLogin(t *testing.T) { + loginEnv(t) + twoTestOrgs(t) + t.Setenv("NULLIFY_TOKEN", "api-token") + require.NoError(t, auth.SaveHostCredentials("acme.nullify.ai", auth.HostCredentials{AccessToken: "tenant"})) + + statusCmd.SetContext(context.Background()) + out := captureStdout(t, func() { require.NoError(t, statusCmd.RunE(statusCmd, nil)) }) + require.Contains(t, out, "Auth: API token") + require.Contains(t, out, "overrides your 'nullify auth login' session") +} + +// 'nullify org use' with an org on another tenant of the router moves plain +// commands there: the router's selected data host, the default host and that +// tenant's default all follow, as 'auth switch --installation' does. +func TestOrgUseMovesToAnotherTenantOfTheRouter(t *testing.T) { + loginEnv(t) + beta := auth.Org{Name: "beta-org", Provider: "github", Key: "githubOwnerId", ID: "3003003", Host: "api.beta.nullify.ai"} + stubOrgList(t, func(string) (auth.OrgList, error) { + return auth.OrgList{Orgs: []auth.Org{testOrgA, testOrgB}, Elsewhere: []auth.Org{beta}}, nil + }) + + orgUseCmd.SetContext(context.Background()) + captureStdout(t, func() { require.NoError(t, orgUseCmd.RunE(orgUseCmd, []string{"3003003"})) }) + + creds, err := auth.LoadCredentials() + require.NoError(t, err) + require.Equal(t, "beta.nullify.ai", creds["nullify.ai"].DataHost) + require.Equal(t, map[string]string{"githubOwnerId": "3003003"}, creds["nullify.ai"].QueryParameters) + cfg, err := auth.LoadConfig() + require.NoError(t, err) + require.Equal(t, "nullify.ai", cfg.Host) + authHost, err := auth.AuthHostFor("beta.nullify.ai") + require.NoError(t, err) + require.Equal(t, "api.nullify.ai", authHost) + require.Equal(t, "3003003", auth.StoredPick("beta.nullify.ai").ID) +} + +// The hidden 'auth switch --installation' alias saves through the same writer +// as 'nullify org use', and refuses a host that is not a router. +func TestAuthSwitchInstallationAlias(t *testing.T) { + loginEnv(t) + fake := &fakeNullify{live: "router-token"} + old := http.DefaultTransport + http.DefaultTransport = fake + t.Cleanup(func() { http.DefaultTransport = old }) + require.NoError(t, auth.SaveHostCredentials("nullify.ai", auth.HostCredentials{ + AccessToken: "router-token", RefreshToken: "rt", ExpiresAt: time.Now().Add(time.Hour).Unix(), + })) + require.NoError(t, switchCmd.Flags().Set("installation", "acme-payments")) + t.Cleanup(func() { _ = switchCmd.Flags().Set("installation", "") }) + + host = "acme.nullify.ai" + require.ErrorContains(t, switchCmd.RunE(switchCmd, nil), "applies to a Nullify router host") + + host = "nullify.ai" + switchCmd.SetContext(context.Background()) + captureStdout(t, func() { require.NoError(t, switchCmd.RunE(switchCmd, nil)) }) + creds, err := auth.LoadCredentials() + require.NoError(t, err) + require.Equal(t, "acme.nullify.ai", creds["nullify.ai"].DataHost) + require.Equal(t, map[string]string{"githubOwnerId": "2002002"}, creds["nullify.ai"].QueryParameters) + require.Equal(t, "2002002", auth.StoredPick("acme.nullify.ai").ID, "same default 'org use' writes") +} + +// An invalid host saved in the config is an error, not a reason to use +// another host. +func TestInvalidConfigHostIsAnError(t *testing.T) { + loginEnv(t) + twoTestOrgs(t) + t.Setenv("NULLIFY_HOST", "") + require.NoError(t, auth.SaveConfig(&auth.Config{Host: "api.attacker.example#.nullify.ai"})) + + _, err := resolveCommandAuth(context.Background()) + require.ErrorContains(t, err, "in ~/.nullify/config.json") +} + +// whoami on a router-bound tenant reports the router as its auth host, the +// tenant as its data host and the org commands run as. +func TestWhoamiReportsHostsAndOrg(t *testing.T) { + loginEnv(t) + twoTestOrgs(t) + require.NoError(t, auth.SelectOrg(testOrgB)) + + whoamiCmd.SetContext(context.Background()) + out := captureStdout(t, func() { require.NoError(t, whoamiCmd.RunE(whoamiCmd, nil)) }) + var info map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &info), out) + require.Equal(t, "acme.nullify.ai", info["host"]) + require.Equal(t, "api.nullify.ai", info["auth_host"]) + require.Equal(t, "acme.nullify.ai", info["data_host"]) + require.Equal(t, true, info["authenticated"]) + require.Contains(t, info["org"], "acme-payments") +} + +// auth status and auth config report an invalid NULLIFY_HOST or config host +// as an error, not as "no host configured". +func TestAuthStatusReportsAnInvalidEnvOrConfigHost(t *testing.T) { + loginEnv(t) + t.Setenv("NULLIFY_HOST", "api.attacker.example#.nullify.ai") + statusCmd.SetContext(context.Background()) + require.ErrorContains(t, statusCmd.RunE(statusCmd, nil), "invalid NULLIFY_HOST") + configShowCmd.SetContext(context.Background()) + require.ErrorContains(t, configShowCmd.RunE(configShowCmd, nil), "invalid NULLIFY_HOST") + + t.Setenv("NULLIFY_HOST", "") + require.NoError(t, auth.SaveConfig(&auth.Config{Host: "acme.nullify.ai:8443"})) + require.ErrorContains(t, statusCmd.RunE(statusCmd, nil), "in ~/.nullify/config.json") +} + +// Answering the org prompt during a command on a tenant remembers the org +// for that tenant without changing the default instance. +func TestPromptAnswerDoesNotMoveTheDefaultHost(t *testing.T) { + loginEnv(t) + twoTestOrgs(t) + require.NoError(t, auth.SaveConfig(&auth.Config{Host: "own.nullify.ai"})) + oldIn, oldOut := promptIn, promptOut + promptIn, promptOut = strings.NewReader("2\n"), io.Discard + t.Cleanup(func() { promptIn, promptOut = oldIn, oldOut }) + + _, err := resolveOrgScope(context.Background(), "acme.nullify.ai", "router-token", orgInputsFor("acme.nullify.ai", "", "--org"), true) + require.NoError(t, err) + cfg, err := auth.LoadConfig() + require.NoError(t, err) + require.Equal(t, "own.nullify.ai", cfg.Host) + require.Equal(t, "2002002", auth.StoredPick("acme.nullify.ai").ID) +} + +// 'nullify org list --org ' on the router selects it, +// as a command with the same --org would run there. +func TestOrgListOnTheRouterSelectsAnOrgElsewhere(t *testing.T) { + loginEnv(t) + beta := auth.Org{Name: "beta-org", Provider: "github", Key: "githubOwnerId", ID: "3003003", Host: "api.beta.nullify.ai", Status: "ready"} + stubOrgList(t, func(string) (auth.OrgList, error) { + return auth.OrgList{Orgs: []auth.Org{testOrgA, testOrgB}, Elsewhere: []auth.Org{beta}}, nil + }) + + out, err := orgListing(context.Background(), "acme.nullify.ai", "router-token", auth.OrgInputs{Flag: "beta-org", FlagSource: "--org", AnyHost: true}) + require.NoError(t, err) + require.Equal(t, "beta-org", out["selected"]) + + out, err = orgListing(context.Background(), "acme.nullify.ai", "router-token", auth.OrgInputs{Flag: "beta-org", FlagSource: "--org"}) + require.NoError(t, err) + require.Nil(t, out["selected"], "off the router an org elsewhere is not selectable") +} + +// The prompt's RememberOrg moves the router's selection only when it is +// already on that tenant. +func TestRememberOrgFollowsTheRouterSelectionOnlyOnItsTenant(t *testing.T) { + loginEnv(t) + require.NoError(t, auth.SaveHostCredentials("nullify.ai", auth.HostCredentials{AccessToken: "t", DataHost: "acme.nullify.ai", Installation: "acme-web", + QueryParameters: map[string]string{"githubOwnerId": "1001001"}})) + + require.NoError(t, auth.RememberOrg(testOrgB)) + creds, err := auth.LoadCredentials() + require.NoError(t, err) + require.Equal(t, "acme-payments", creds["nullify.ai"].Installation) + require.Equal(t, map[string]string{"githubOwnerId": "2002002"}, creds["nullify.ai"].QueryParameters) + + beta := auth.Org{Name: "beta-org", Key: "githubOwnerId", ID: "3003003", Host: "api.beta.nullify.ai"} + require.NoError(t, auth.UpdateHostConfig("beta.nullify.ai", func(hc auth.HostConfig) auth.HostConfig { + hc.AuthHost = "api.nullify.ai" + return hc + })) + require.NoError(t, auth.RememberOrg(beta)) + creds, err = auth.LoadCredentials() + require.NoError(t, err) + require.Equal(t, "acme.nullify.ai", creds["nullify.ai"].DataHost, "a pick on another tenant moved the router selection") + require.Equal(t, "3003003", auth.StoredPick("beta.nullify.ai").ID) +} + +// auth status with an API token reports an invalid NULLIFY_HOST as an error, +// and auth config an invalid config host. +func TestAuthStatusAndConfigReportInvalidHosts(t *testing.T) { + loginEnv(t) + t.Setenv("NULLIFY_TOKEN", "api-token") + t.Setenv("NULLIFY_HOST", "api.attacker.example#.nullify.ai") + statusCmd.SetContext(context.Background()) + require.ErrorContains(t, statusCmd.RunE(statusCmd, nil), "invalid NULLIFY_HOST") + + t.Setenv("NULLIFY_TOKEN", "") + t.Setenv("NULLIFY_HOST", "") + require.NoError(t, auth.SaveConfig(&auth.Config{Host: "acme.nullify.ai:8443"})) + configShowCmd.SetContext(context.Background()) + require.ErrorContains(t, configShowCmd.RunE(configShowCmd, nil), "in ~/.nullify/config.json") +} + +// A command on a tenant the router login might cover reports a failed check +// of that, not "not authenticated". +func TestCommandSurfacesAFailedRouterCoverageCheck(t *testing.T) { + loginEnv(t) + t.Setenv("NULLIFY_HOST", "beta.nullify.ai") + oldCover := coverTenant + t.Cleanup(func() { coverTenant = oldCover }) + coverTenant = func(context.Context, string) (bool, error) { + return false, errors.New("could not check whether your nullify.ai login covers beta.nullify.ai: status 502") + } + + _, err := resolveCommandAuth(context.Background()) + require.ErrorContains(t, err, "could not check whether your nullify.ai login covers beta.nullify.ai") +} + +// 'nullify org use' moving to another instance warns when NULLIFY_HOST still +// pins plain commands to the old one, in any spelling of it, and says +// nothing when the org is on the pinned tenant, when the pin is the router, +// when nothing is pinned, or when --host names this command's host only. +func TestOrgUseNotesAPinnedNullifyHost(t *testing.T) { + beta := auth.Org{Name: "beta-org", Provider: "github", Key: "githubOwnerId", ID: "3003003", Host: "api.beta.nullify.ai", Status: "ready"} + run := func(t *testing.T, envHost, flagHost, org string) string { + t.Helper() + loginEnv(t) + stubOrgList(t, func(string) (auth.OrgList, error) { + return auth.OrgList{Orgs: []auth.Org{testOrgA, testOrgB}, Elsewhere: []auth.Org{beta}}, nil + }) + t.Setenv("NULLIFY_HOST", envHost) + host = flagHost + if envHost == "" && flagHost == "" { + // The saved host is a default, not a pin. + require.NoError(t, auth.SaveConfig(&auth.Config{Host: "acme.nullify.ai"})) + } + var note bytes.Buffer + oldOut := promptOut + promptOut = ¬e + t.Cleanup(func() { promptOut = oldOut }) + orgUseCmd.SetContext(context.Background()) + captureStdout(t, func() { require.NoError(t, orgUseCmd.RunE(orgUseCmd, []string{org})) }) + return note.String() + } + + for _, env := range []string{"acme.nullify.ai", "https://ACME.nullify.ai/", "acme", "api.acme.nullify.ai"} { + t.Run("moved away from "+env, func(t *testing.T) { + require.Contains(t, run(t, env, "", "beta-org"), "NULLIFY_HOST still points commands at acme.nullify.ai") + }) + t.Run("stayed on "+env, func(t *testing.T) { + require.Empty(t, run(t, env, "", "acme-payments")) + }) + } + t.Run("--host alone pins nothing", func(t *testing.T) { + require.Empty(t, run(t, "", "acme.nullify.ai", "beta-org")) + }) + t.Run("--host with NULLIFY_HOST still notes the env pin", func(t *testing.T) { + require.Contains(t, run(t, "acme.nullify.ai", "acme.nullify.ai", "beta-org"), "NULLIFY_HOST still points commands at acme.nullify.ai") + }) + t.Run("pinned to the router", func(t *testing.T) { + require.Empty(t, run(t, "nullify.ai", "acme.nullify.ai", "beta-org")) + }) + t.Run("only a saved host", func(t *testing.T) { + require.Empty(t, run(t, "", "", "beta-org")) + }) +} + +// 'nullify org list --org ' on the router, run as a +// command, selects it as the command would. +func TestOrgListCommandOnTheRouterSelectsAnOrgElsewhere(t *testing.T) { + loginEnv(t) + t.Setenv("NULLIFY_HOST", "nullify.ai") + require.NoError(t, auth.SaveHostCredentials("nullify.ai", auth.HostCredentials{ + AccessToken: "router-token", RefreshToken: "rt", ExpiresAt: time.Now().Add(time.Hour).Unix(), DataHost: "acme.nullify.ai", + })) + beta := auth.Org{Name: "beta-org", Provider: "github", Key: "githubOwnerId", ID: "3003003", Host: "api.beta.nullify.ai", Status: "ready"} + stubOrgList(t, func(string) (auth.OrgList, error) { + return auth.OrgList{Orgs: []auth.Org{testOrgA, testOrgB}, Elsewhere: []auth.Org{beta}}, nil + }) + orgFlag = "beta-org" + + orgListCmd.SetContext(context.Background()) + out := captureStdout(t, func() { require.NoError(t, orgListCmd.RunE(orgListCmd, nil)) }) + var listed map[string]any + require.NoError(t, json.Unmarshal([]byte(out), &listed), out) + require.Equal(t, "beta-org", listed["selected"]) +} + +// auth status on a router login with no selected data host points to a new +// login, not to 'nullify org use', which cannot run without one. +func TestAuthStatusWithoutADataHostPointsToLogin(t *testing.T) { + loginEnv(t) + t.Setenv("NULLIFY_HOST", "nullify.ai") + require.NoError(t, auth.SaveHostCredentials("nullify.ai", auth.HostCredentials{AccessToken: "router-token", ExpiresAt: time.Now().Add(time.Hour).Unix()})) + + statusCmd.SetContext(context.Background()) + out := captureStdout(t, func() { require.NoError(t, statusCmd.RunE(statusCmd, nil)) }) + require.Contains(t, out, "Data host: none selected - run 'nullify auth login'") +} diff --git a/cmd/cli/cmd/root.go b/cmd/cli/cmd/root.go index becf611..e9b2c0a 100644 --- a/cmd/cli/cmd/root.go +++ b/cmd/cli/cmd/root.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "errors" "fmt" "os" "sync" @@ -25,6 +26,7 @@ type commandOutput struct { var ( host string + orgFlag string verbose bool debug bool quiet bool @@ -121,6 +123,7 @@ func resetCommandOutput( func init() { rootCmd.PersistentFlags().StringVar(&host, "host", "", "Your Nullify instance (e.g., acme.nullify.ai), or nullify.ai to sign in through the Nullify router") + rootCmd.PersistentFlags().StringVar(&orgFlag, "org", "", "Organization to run as, by name or id, when your login covers several (see 'nullify org list')") rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose logging") rootCmd.PersistentFlags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging") rootCmd.PersistentFlags().StringVarP(&outputFmt, "output", "o", "json", "Output format (json, table, yaml, sarif)") @@ -197,36 +200,36 @@ func resolveHostE(ctx context.Context) (string, error) { return sanitized, nil } + // An invalid host is an error, never a reason to use another one: the + // token would go to a host the user did not name. if envHost := os.Getenv("NULLIFY_HOST"); envHost != "" { sanitized, err := lib.SanitizeNullifyHost(envHost) - if err == nil { - return sanitized, nil + if err != nil { + return "", withExitCode(1, fmt.Errorf("invalid NULLIFY_HOST %q, must be nullify.ai or .nullify.ai", envHost)) } - logger.L(ctx).Warn("NULLIFY_HOST env var is invalid, falling through to config", logger.String("host", envHost), logger.Err(err)) + return sanitized, nil } cfg, err := auth.LoadConfig() if err == nil && cfg.Host != "" { sanitized, err := lib.SanitizeNullifyHost(cfg.Host) - if err == nil { - return sanitized, nil + if err != nil { + return "", withExitCode(1, fmt.Errorf("invalid host %q in ~/.nullify/config.json, must be nullify.ai or .nullify.ai", cfg.Host)) } - logger.L(ctx).Warn("config file host is invalid, ignoring", logger.String("host", cfg.Host), logger.Err(err)) + return sanitized, nil } - return "", authError("no host configured. Run 'nullify init' to set up, or 'nullify auth login' to sign in.") + return "", authError("%w. Run 'nullify init' to set up, or 'nullify auth login' to sign in.", errNoHost) } +// errNoHost reports that no --host, NULLIFY_HOST or configured host was set, +// as opposed to one that was set and is invalid. +var errNoHost = errors.New("no host configured") + func getNullifyClientE(ctx context.Context) (*client.NullifyClient, error) { - authCtx, err := resolveCommandHosts(ctx) + authCtx, err := resolveCommandToken(ctx) if err != nil { return nil, err } - - authCtx.Token, err = lib.GetNullifyToken(ctx, authCtx.AuthHost, nullifyToken, githubToken) - if err != nil { - return nil, authError("failed to get token. Run 'nullify auth login' to authenticate: %w", err) - } - return authCtx.Client(), nil } diff --git a/cmd/cli/cmd/runtime_auth.go b/cmd/cli/cmd/runtime_auth.go index 46be257..2d91bbf 100644 --- a/cmd/cli/cmd/runtime_auth.go +++ b/cmd/cli/cmd/runtime_auth.go @@ -3,7 +3,6 @@ package cmd import ( "context" "errors" - "maps" "os" "github.com/nullify-platform/cli/internal/api" @@ -19,10 +18,19 @@ func stdinIsTTY() bool { // commandAuthContext separates the host that issues tokens from the host that serves data. type commandAuthContext struct { - AuthHost string - DataHost string + // AuthHost issues and refreshes the token: the router for a router + // login, else the data host itself. + AuthHost string + // DataHost serves the command's requests. + DataHost string + // OnRouter is set when the command named the router rather than a + // tenant, so an explicit org may move it to another tenant. + OnRouter bool Token string QueryParams map[string]string + // Org is the org the command runs as, nil when the server takes it from + // an API token or it was given only as an id. + Org *auth.Org } // Client builds a NullifyClient for the data host and token. @@ -34,53 +42,38 @@ func (c *commandAuthContext) APIClient() *api.Client { return api.NewClient(c.DataHost, c.Token, c.QueryParams) } -// resolveCommandHosts sets the auth and data hosts for the command host; only the router host has different ones. -func resolveCommandHosts(ctx context.Context) (*commandAuthContext, error) { - commandHost, err := resolveHostE(ctx) +// coverTenant is auth.CoverTenant, a var so tests need no router. +var coverTenant = auth.CoverTenant + +// resolveCommandAuth resolves the hosts, token and org a command runs with. +// Stored credentials no longer carry the org: a login's saved +// query_parameters were the server's default, not the user's choice. +func resolveCommandAuth(ctx context.Context) (*commandAuthContext, error) { + authCtx, err := resolveCommandToken(ctx) if err != nil { return nil, err } - return commandHostsFor(commandHost) -} - -func commandHostsFor(commandHost string) (*commandAuthContext, error) { - if !auth.IsRouterHost(commandHost) { - return &commandAuthContext{AuthHost: commandHost, DataHost: commandHost}, nil - } - dataHost, err := auth.DataHost(auth.RouterHost) - if errors.Is(err, auth.ErrNoDataHost) && explicitTokenSet() { - return nil, authError("nullify.ai serves sign-in only - with --nullify-token or NULLIFY_TOKEN, set --host or NULLIFY_HOST to .nullify.ai") - } + in := orgInputsFor(authCtx.DataHost, orgFlag, "--org") + in.AnyHost = authCtx.OnRouter + scope, err := resolveOrgScope(ctx, authCtx.DataHost, authCtx.Token, in, stdinIsTTY()) if err != nil { return nil, authError("%w", err) } - return &commandAuthContext{AuthHost: auth.RouterHost, DataHost: dataHost}, nil -} - -func explicitTokenSet() bool { - if nullifyToken != "" || os.Getenv("NULLIFY_TOKEN") != "" { - return true - } - return os.Getenv("GITHUB_ACTIONS") == "true" && githubToken != "" && os.Getenv("GITHUB_REPOSITORY") != "" -} - -// routerSignInHint returns a hint when tenantHost has no credentials but is the data host of the router sign-in. -func routerSignInHint(tenantHost string) string { - creds, err := auth.LoadCredentials() - if err != nil { - return "" + if scope.Org != nil && scope.Org.DataHost() != "" && scope.Org.DataHost() != auth.CredentialKey(authCtx.DataHost) { + authCtx.DataHost = scope.Org.DataHost() } - if _, ok := creds[auth.CredentialKey(tenantHost)]; ok { - return "" + if scope.Token != "" { + authCtx.Token = scope.Token } - if creds[auth.RouterHost].DataHost != tenantHost { - return "" - } - return " (signed in through nullify.ai - use --host nullify.ai or 'nullify auth switch --host nullify.ai')" + authCtx.Org = scope.Org + authCtx.QueryParams = scope.Query + return authCtx, nil } -func resolveCommandAuth(ctx context.Context) (*commandAuthContext, error) { +// resolveCommandToken resolves the hosts and token only, for callers that +// choose the org themselves. +func resolveCommandToken(ctx context.Context) (*commandAuthContext, error) { authCtx, err := resolveCommandHosts(ctx) if err != nil { return nil, err @@ -91,26 +84,54 @@ func resolveCommandAuth(ctx context.Context) (*commandAuthContext, error) { if errors.Is(err, lib.ErrNoToken) { return nil, authError("not authenticated. Run 'nullify auth login' first") } - if !auth.IsRouterHost(authCtx.AuthHost) { - return nil, authError("%w%s", err, routerSignInHint(authCtx.AuthHost)) - } return nil, authError("%w", err) } + authCtx.QueryParams = map[string]string{} + return authCtx, nil +} - queryParams := map[string]string{} - creds, err := auth.LoadCredentials() - if err == nil { - maps.Copy(queryParams, creds[auth.CredentialKey(authCtx.AuthHost)].QueryParameters) +// resolveCommandHosts sets the auth and data hosts for the command host. +func resolveCommandHosts(ctx context.Context) (*commandAuthContext, error) { + commandHost, err := resolveHostE(ctx) + if err != nil { + return nil, err } + return commandHostsFor(ctx, commandHost) +} - // NULLIFY_GITHUB_OWNER_ID env var provides the githubOwnerId query param - // when using NULLIFY_TOKEN directly (no stored credentials). - if ownerID := os.Getenv("NULLIFY_GITHUB_OWNER_ID"); ownerID != "" { - if _, set := queryParams["githubOwnerId"]; !set { - queryParams["githubOwnerId"] = ownerID +// commandHostsFor maps the host a command names to the host that signs it in +// and the host that serves it. The router serves sign-in only: its data host +// is the tenant of the selected org. A tenant signs in through the router it +// is bound to, through a router login that lists it, or by itself. An API or +// GitHub Actions token always goes to the tenant it names. +func commandHostsFor(ctx context.Context, commandHost string) (*commandAuthContext, error) { + if router, ok := auth.RouterFor(commandHost); ok { + if usesMachineToken() { + return nil, authError("%s serves sign-in only - with --nullify-token or NULLIFY_TOKEN, set --host or NULLIFY_HOST to .nullify.ai", router.Key) + } + dataHost, err := auth.DataHost(router.Key) + if err != nil { + return nil, authError("%w", err) + } + // A CLI v0.26.0 login selected its data host without binding it. + if err := auth.BindTenant(dataHost, router); err != nil { + return nil, authError("%w", err) } + return &commandAuthContext{AuthHost: router.Key, DataHost: dataHost, OnRouter: true}, nil } - authCtx.QueryParams = queryParams - return authCtx, nil + if usesMachineToken() { + return &commandAuthContext{AuthHost: commandHost, DataHost: commandHost}, nil + } + + if _, _, ok, err := auth.LookupCredentials(commandHost); err == nil && !ok { + if _, err := coverTenant(ctx, commandHost); err != nil { + return nil, authError("%w", err) + } + } + authHost, err := auth.AuthHostFor(commandHost) + if err != nil { + return nil, authError("%w", err) + } + return &commandAuthContext{AuthHost: authHost, DataHost: commandHost}, nil } diff --git a/cmd/cli/cmd/runtime_auth_test.go b/cmd/cli/cmd/runtime_auth_test.go index bfd1378..92a2ae4 100644 --- a/cmd/cli/cmd/runtime_auth_test.go +++ b/cmd/cli/cmd/runtime_auth_test.go @@ -35,10 +35,15 @@ func TestResolveCommandAuthUsesEnvTokenWithoutStoredCredentials(t *testing.T) { require.Empty(t, authCtx.QueryParams) } -func TestResolveCommandAuthClonesStoredQueryParams(t *testing.T) { +// Logins before this CLI saved the server's default org (the lowest id) as +// query_parameters. That was never the user's choice, so it must not scope +// requests: an API token leaves the org to the server instead. +func TestResolveCommandAuthIgnoresLegacySavedScope(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Setenv("NULLIFY_HOST", "acme.nullify.ai") t.Setenv("NULLIFY_TOKEN", "env-token") + t.Setenv("NULLIFY_ORG", "") + t.Setenv("NULLIFY_GITHUB_OWNER_ID", "") err := auth.SaveHostCredentials("acme.nullify.ai", auth.HostCredentials{ AccessToken: "stored-token", @@ -49,6 +54,10 @@ func TestResolveCommandAuthClonesStoredQueryParams(t *testing.T) { }, }) require.NoError(t, err) + stubOrgList(t, func(string) (auth.OrgList, error) { + t.Error("an API token with no explicit org must not list orgs") + return auth.OrgList{}, nil + }) originalHost := host originalNullifyToken := nullifyToken @@ -64,13 +73,7 @@ func TestResolveCommandAuthClonesStoredQueryParams(t *testing.T) { authCtx, err := resolveCommandAuth(context.Background()) require.NoError(t, err) - require.Equal(t, map[string]string{"githubOwnerId": "123"}, authCtx.QueryParams) - - authCtx.QueryParams["githubOwnerId"] = "456" - - creds, err := auth.LoadCredentials() - require.NoError(t, err) - require.Equal(t, "123", creds[auth.CredentialKey("acme.nullify.ai")].QueryParameters["githubOwnerId"]) + require.Empty(t, authCtx.QueryParams) } func resetCommandFlags(t *testing.T) { @@ -78,13 +81,16 @@ func resetCommandFlags(t *testing.T) { originalHost := host originalNullifyToken := nullifyToken originalGithubToken := githubToken + originalCoverTenant := coverTenant host = "" nullifyToken = "" githubToken = "" + coverTenant = func(_ context.Context, tenant string) (bool, error) { return auth.CoverSelectedTenant(tenant) } t.Cleanup(func() { host = originalHost nullifyToken = originalNullifyToken githubToken = originalGithubToken + coverTenant = originalCoverTenant }) } @@ -113,10 +119,8 @@ func TestResolveCommandAuthHosts(t *testing.T) { wantAuthHost string wantDataHost string wantToken string - wantParams map[string]string wantErr string githubCI bool - noHint bool }{ { name: "router config uses the selected installation for data", @@ -126,7 +130,6 @@ func TestResolveCommandAuthHosts(t *testing.T) { wantAuthHost: "nullify.ai", wantDataHost: "acme.nullify.ai", wantToken: "router-token", - wantParams: map[string]string{"githubOwnerId": "7"}, }, { name: "router alias flag", @@ -136,14 +139,18 @@ func TestResolveCommandAuthHosts(t *testing.T) { wantAuthHost: "nullify.ai", wantDataHost: "acme.nullify.ai", wantToken: "router-token", - wantParams: map[string]string{"githubOwnerId": "7"}, + }, + { + name: "dev router alias flag", + flagHost: "app.router.dev.nullify.ai", + wantErr: "no organization selected", }, { name: "router without a selected installation fails", configHost: "nullify.ai", routerCreds: true, routerData: "", - wantErr: "no installation selected", + wantErr: "no organization selected", }, { name: "router with an env token and no sign-in points CI at the instance", @@ -158,19 +165,20 @@ func TestResolveCommandAuthHosts(t *testing.T) { wantErr: "set --host or NULLIFY_HOST to .nullify.ai", }, { - name: "tenant host ignores the router selection but hints at it", - envHost: "acme.nullify.ai", - routerCreds: true, - routerData: "acme.nullify.ai", - wantErr: "not authenticated for acme.nullify.ai - run 'nullify auth login --host acme.nullify.ai' (signed in through nullify.ai - use --host nullify.ai or 'nullify auth switch --host nullify.ai')", + name: "tenant host the router login selected signs in through the router", + envHost: "acme.nullify.ai", + routerCreds: true, + routerData: "acme.nullify.ai", + wantAuthHost: "api.nullify.ai", + wantDataHost: "acme.nullify.ai", + wantToken: "router-token", }, { - name: "tenant host without a matching router selection has no hint", + name: "tenant host the router does not cover is not authenticated", envHost: "beta.nullify.ai", routerCreds: true, routerData: "acme.nullify.ai", wantErr: "not authenticated for beta.nullify.ai - run 'nullify auth login --host beta.nullify.ai'", - noHint: true, }, { name: "tenant env token ignores router query parameters", @@ -181,7 +189,6 @@ func TestResolveCommandAuthHosts(t *testing.T) { wantAuthHost: "acme.nullify.ai", wantDataHost: "acme.nullify.ai", wantToken: "env-token", - wantParams: map[string]string{}, }, { name: "tenant host uses its own credentials", @@ -192,7 +199,6 @@ func TestResolveCommandAuthHosts(t *testing.T) { wantAuthHost: "acme.nullify.ai", wantDataHost: "acme.nullify.ai", wantToken: "tenant-token", - wantParams: map[string]string{"githubOwnerId": "1"}, }, { name: "other tenant stays on its own host", @@ -203,7 +209,6 @@ func TestResolveCommandAuthHosts(t *testing.T) { wantAuthHost: "beta.nullify.ai", wantDataHost: "beta.nullify.ai", wantToken: "env-token", - wantParams: map[string]string{}, }, } for _, tt := range tests { @@ -236,19 +241,15 @@ func TestResolveCommandAuthHosts(t *testing.T) { })) } - authCtx, err := resolveCommandAuth(context.Background()) + authCtx, err := resolveCommandToken(context.Background()) if tt.wantErr != "" { require.ErrorContains(t, err, tt.wantErr) - if tt.noHint { - require.NotContains(t, err.Error(), "signed in through nullify.ai") - } return } require.NoError(t, err) require.Equal(t, tt.wantAuthHost, authCtx.AuthHost) require.Equal(t, tt.wantDataHost, authCtx.DataHost) require.Equal(t, tt.wantToken, authCtx.Token) - require.Equal(t, tt.wantParams, authCtx.QueryParams) require.Equal(t, "https://api."+tt.wantDataHost, authCtx.APIClient().BaseURL) require.Equal(t, "https://api."+tt.wantDataHost, authCtx.Client().BaseURL) @@ -270,8 +271,11 @@ func TestDashboardURL(t *testing.T) { } } +// A router login's MCP server sends requests to the selected data host and +// gets and refreshes its token at the router, which the data host is bound to. func TestMCPClientUsesAuthHostForTokensAndDataHostForRequests(t *testing.T) { t.Setenv("HOME", t.TempDir()) + t.Setenv("NULLIFY_HOST", "nullify.ai") t.Setenv("NULLIFY_TOKEN", "") t.Setenv("GITHUB_ACTIONS", "") resetCommandFlags(t) @@ -280,21 +284,24 @@ func TestMCPClientUsesAuthHostForTokensAndDataHostForRequests(t *testing.T) { ExpiresAt: time.Now().Add(time.Hour).Unix(), DataHost: "acme.nullify.ai", })) - authCtx := &commandAuthContext{AuthHost: auth.RouterHost, DataHost: "acme.nullify.ai"} - apiClient, err := newMCPAPIClient(context.Background(), authCtx, map[string]string{"repository": "r"}) + apiClient, _, _, err := mcpServeConfig(context.Background(), "r", "default") require.NoError(t, err) require.Equal(t, "https://api.acme.nullify.ai", apiClient.BaseURL) require.Equal(t, map[string]string{"repository": "r"}, apiClient.DefaultParams) - tokenProvider, refreshProvider := mcpTokenProviders(context.Background(), authCtx.AuthHost) + tokenProvider, refreshProvider := mcpTokenProviders(context.Background(), auth.RouterHost) token, err := tokenProvider() require.NoError(t, err) require.Equal(t, "router-token", token) _, err = refreshProvider() require.ErrorIs(t, err, client.ErrTokenNotRefreshable, "refresh must look up the router credentials") - dataTokenProvider, _ := mcpTokenProviders(context.Background(), authCtx.DataHost) - _, err = dataTokenProvider() - require.ErrorContains(t, err, "not authenticated for acme.nullify.ai") + authHost, err := auth.AuthHostFor("acme.nullify.ai") + require.NoError(t, err) + require.Equal(t, "api.nullify.ai", authHost, "the selected data host is bound to the router") + dataTokenProvider, _ := mcpTokenProviders(context.Background(), "acme.nullify.ai") + token, err = dataTokenProvider() + require.NoError(t, err) + require.Equal(t, "router-token", token) } diff --git a/cmd/cli/cmd/whoami.go b/cmd/cli/cmd/whoami.go index e50ccc6..7753b4e 100644 --- a/cmd/cli/cmd/whoami.go +++ b/cmd/cli/cmd/whoami.go @@ -24,30 +24,26 @@ var whoamiCmd = &cobra.Command{ "host": whoamiHost, } - hosts, hostsErr := commandHostsFor(whoamiHost) - if hostsErr == nil && hosts.AuthHost != hosts.DataHost { + hosts, hostsErr := commandHostsFor(ctx, whoamiHost) + if hostsErr == nil { info["auth_host"] = hosts.AuthHost info["data_host"] = hosts.DataHost } - creds, err := auth.LoadCredentials() - if hostsErr != nil { + hostCreds, _, ok, err := auth.LookupCredentials(whoamiHost) + switch { + case hostsErr != nil: info["authenticated"] = false info["error"] = hostsErr.Error() - } else if err != nil { + case err != nil: info["authenticated"] = false info["error"] = "no credentials found" - } else if hostCreds, ok := creds[auth.CredentialKey(hosts.AuthHost)]; !ok { + case !ok: info["authenticated"] = false info["error"] = fmt.Sprintf("no credentials for host %s", hosts.AuthHost) - } else { + default: info["authenticated"] = true - if hostCreds.Installation != "" { - info["installation"] = hostCreds.Installation - } - if hostCreds.QueryParameters != nil { - info["query_parameters"] = hostCreds.QueryParameters - } + info["org"] = describeOrg(ctx, whoamiHost) if hostCreds.ExpiresAt > 0 { info["expires_at"] = hostCreds.ExpiresAt } diff --git a/go.mod b/go.mod index 4cf5dbb..d922de2 100644 --- a/go.mod +++ b/go.mod @@ -90,6 +90,7 @@ require ( ) require ( + github.com/gofrs/flock v0.13.0 github.com/nullify-platform/logger v1.32.2 github.com/stretchr/testify v1.11.1 go.uber.org/zap v1.28.0 diff --git a/go.sum b/go.sum index e44e02c..6a92a90 100644 --- a/go.sum +++ b/go.sum @@ -80,6 +80,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= diff --git a/internal/api/bearer_test.go b/internal/api/bearer_test.go new file mode 100644 index 0000000..08994c6 --- /dev/null +++ b/internal/api/bearer_test.go @@ -0,0 +1,72 @@ +package api + +import ( + "context" + "io" + "net/http" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +// bearerRecorder answers every request, redirecting the API host's /to-* +// paths, and records the Authorization each URL received. +type bearerRecorder struct { + mu sync.Mutex + auth map[string]string +} + +func (b *bearerRecorder) RoundTrip(r *http.Request) (*http.Response, error) { + b.mu.Lock() + b.auth[r.URL.String()] = r.Header.Get("Authorization") + b.mu.Unlock() + resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: io.NopCloser(strings.NewReader("{}")), Request: r} + redirects := map[string]string{ + "/to-foreign": "https://bucket.s3.amazonaws.com/report?X-Amz-Signature=x", + "/to-same": "https://api.acme.nullify.ai/landing", + "/to-plain": "http://api.acme.nullify.ai/plain", + "/to-subdomai": "https://evil.api.acme.nullify.ai/sub", + } + if loc, ok := redirects[r.URL.Path]; ok && r.URL.Host == "api.acme.nullify.ai" { + resp.StatusCode = http.StatusFound + resp.Header.Set("Location", loc) + } + return resp, nil +} + +// The generated client sends the token only to the API host it was built +// for, over https, and never on a redirect hop: not to another host, not +// over http, and not back to its own host by way of a redirect. +func TestGeneratedClientSendsTheTokenOnlyToItsAPIHost(t *testing.T) { + rec := &bearerRecorder{auth: map[string]string{}} + c := NewClient("acme.nullify.ai", "secret", nil) + c.HTTPClient.Transport = rec + + for _, path := range []string{"/ok", "/to-foreign", "/to-same", "/to-plain", "/to-subdomai"} { + _, err := c.do(context.Background(), http.MethodGet, c.BaseURL+path, nil) + require.NoError(t, err, path) + } + _, err := c.do(context.Background(), http.MethodGet, "https://api.other.nullify.ai/x", nil) + require.NoError(t, err) + _, err = c.do(context.Background(), http.MethodGet, "http://api.acme.nullify.ai/x", nil) + require.NoError(t, err) + + rec.mu.Lock() + defer rec.mu.Unlock() + require.Equal(t, "Bearer secret", rec.auth["https://api.acme.nullify.ai/ok"]) + require.Equal(t, "Bearer secret", rec.auth["https://api.acme.nullify.ai/to-foreign"], "the first hop is the API host") + for _, url := range []string{ + "https://bucket.s3.amazonaws.com/report?X-Amz-Signature=x", + "https://api.acme.nullify.ai/landing", + "http://api.acme.nullify.ai/plain", + "https://evil.api.acme.nullify.ai/sub", + "https://api.other.nullify.ai/x", + "http://api.acme.nullify.ai/x", + } { + auth, seen := rec.auth[url] + require.True(t, seen, url) + require.Empty(t, auth, url) + } +} diff --git a/internal/api/client.go b/internal/api/client.go index dd13ab6..fe875b9 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -34,6 +34,9 @@ func httpTimeout() time.Duration { // Client is a typed HTTP client for the Nullify API. type Client struct { + // host is the API host the client was built for, the only one its token + // is sent to. + host string BaseURL string Token string DefaultParams map[string]string @@ -57,12 +60,14 @@ func NewClient(host string, token string, defaultParams map[string]string, opts apiHost = "api." + host } c := &Client{ + host: apiHost, BaseURL: "https://" + apiHost, Token: token, DefaultParams: defaultParams, HTTPClient: &http.Client{ - Timeout: httpTimeout(), - Transport: client.NewRetryTransport(http.DefaultTransport), + Timeout: httpTimeout(), + Transport: client.NewRetryTransport(http.DefaultTransport), + CheckRedirect: client.StripBearerOnRedirect, }, } for _, opt := range opts { @@ -77,7 +82,11 @@ func (c *Client) do(ctx context.Context, method, url string, body io.Reader) ([] return nil, err } - req.Header.Set("Authorization", "Bearer "+c.Token) + // The token goes only to the API host this client was built for, over + // https; a URL anywhere else is requested without it. + if client.BearerAllowed(c.host, req) { + req.Header.Set("Authorization", "Bearer "+c.Token) + } req.Header.Set("User-Agent", "Nullify-CLI/"+Version) if body != nil { req.Header.Set("Content-Type", "application/json") diff --git a/internal/api/client_test.go b/internal/api/client_test.go index e7cd800..e4f5478 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "net/http/httptest" + "net/url" "testing" "time" @@ -21,13 +22,12 @@ func TestClientDo_Success(t *testing.T) { })) defer server.Close() - client := &Client{ - BaseURL: server.URL, - Token: "test-token", - HTTPClient: server.Client(), - } + // The token goes only to the client's own API host, so the request is + // made there and delivered to the test server underneath. + client := NewClient("acme.nullify.ai", "test-token", nil) + client.HTTPClient = &http.Client{Transport: deliverTo(server)} - result, err := client.do(context.Background(), "GET", server.URL+"/test", nil) + result, err := client.do(context.Background(), "GET", client.BaseURL+"/test", nil) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -151,3 +151,17 @@ func TestNewClient_WithHTTPClientOverride(t *testing.T) { t.Error("expected WithHTTPClient to override the default client") } } + +// deliverTo sends every request to server, whatever host it names. +func deliverTo(server *httptest.Server) http.RoundTripper { + return roundTripFunc(func(r *http.Request) (*http.Response, error) { + out := r.Clone(r.Context()) + target, _ := url.Parse(server.URL) + out.URL.Scheme, out.URL.Host, out.Host = target.Scheme, target.Host, "" + return http.DefaultTransport.RoundTrip(out) + }) +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } diff --git a/internal/auth/auth_host_test.go b/internal/auth/auth_host_test.go new file mode 100644 index 0000000..33a823a --- /dev/null +++ b/internal/auth/auth_host_test.go @@ -0,0 +1,519 @@ +package auth + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +const ( + tenantHost = "acme.nullify.ai" + tenantAPI = "api.acme.nullify.ai" + routerAPI = "api.nullify.ai" +) + +// hostCall is one request the routed auth server saw: the API host the CLI +// addressed and the path it asked for. +type hostCall struct{ host, path string } + +// newRoutedAuthServer serves every auth host from one httptest server, with +// the addressed host as the first path segment, so a test can tell which +// host each call went to. It isolates the credential store like +// newAuthServer. +func newRoutedAuthServer(t *testing.T, h func(w http.ResponseWriter, r *http.Request, host, path string)) *[]hostCall { + t.Helper() + var ( + mu sync.Mutex + calls []hostCall + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host, path, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/") + path = "/" + path + mu.Lock() + calls = append(calls, hostCall{host, path}) + mu.Unlock() + h(w, r, host, path) + })) + oldBase, oldClient := authBaseURL, httpClient + authBaseURL = func(host string) string { return srv.URL + "/" + apiHost(host) } + httpClient = srv.Client() + t.Cleanup(func() { + authBaseURL, httpClient = oldBase, oldClient + srv.Close() + }) + t.Setenv("HOME", t.TempDir()) + return &calls +} + +func authConfig(w http.ResponseWriter, authHost string) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"auth_host": authHost}) +} + +func TestDiscoverAuthHost(t *testing.T) { + tests := []struct { + name string + handler func(w http.ResponseWriter) + want string + wantErr string + }{ + {"router", func(w http.ResponseWriter) { authConfig(w, "api.nullify.ai") }, routerAPI, ""}, + {"dev router", func(w http.ResponseWriter) { authConfig(w, "api.router.dev.nullify.ai") }, "api.router.dev.nullify.ai", ""}, + {"router with port 443 and case", func(w http.ResponseWriter) { authConfig(w, "API.Nullify.AI:443") }, routerAPI, ""}, + {"self", func(w http.ResponseWriter) { authConfig(w, tenantAPI) }, tenantAPI, ""}, + {"legacy stack without the endpoint", func(w http.ResponseWriter) { http.NotFound(w, nil) }, tenantAPI, ""}, + {"server error fails closed", func(w http.ResponseWriter) { w.WriteHeader(http.StatusInternalServerError) }, "", "status 500"}, + {"forbidden fails closed", func(w http.ResponseWriter) { w.WriteHeader(http.StatusForbidden) }, "", "status 403"}, + {"foreign host fails closed", func(w http.ResponseWriter) { authConfig(w, "api.evil.example") }, "", "neither this tenant nor a Nullify router"}, + {"another tenant fails closed", func(w http.ResponseWriter) { authConfig(w, "api.other.nullify.ai") }, "", "neither this tenant nor a Nullify router"}, + {"router with another port fails closed", func(w http.ResponseWriter) { authConfig(w, "api.nullify.ai:8443") }, "", "neither this tenant nor a Nullify router"}, + {"empty answer fails closed", func(w http.ResponseWriter) { authConfig(w, "") }, "", "neither this tenant nor a Nullify router"}, + {"garbage body fails closed", func(w http.ResponseWriter) { _, _ = w.Write([]byte("")) }, "", "decode auth config"}, + {"redirect fails closed", func(w http.ResponseWriter) { + w.Header().Set("Location", "https://api.evil.example/auth/cli/config") + w.WriteHeader(http.StatusTemporaryRedirect) + }, "", "status 307"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + calls := newRoutedAuthServer(t, func(w http.ResponseWriter, _ *http.Request, _, _ string) { + tt.handler(w) + }) + + got, err := DiscoverAuthHost(context.Background(), tenantHost) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("err = %v, want one containing %q", err, tt.wantErr) + } + if got != "" { + t.Errorf("host = %q alongside an error, want none", got) + } + } else { + if err != nil { + t.Fatalf("DiscoverAuthHost: %v", err) + } + if got != tt.want { + t.Errorf("auth host = %q, want %q", got, tt.want) + } + } + if len(*calls) != 1 || (*calls)[0] != (hostCall{tenantAPI, "/auth/cli/config"}) { + t.Errorf("calls = %v, want exactly one config lookup on the tenant", *calls) + } + }) + } +} + +// fakeRouterLogin answers the tenant's config with the router and runs the +// session and redeem on the router. Anything else is a test failure. +func fakeRouterLogin(t *testing.T) func(w http.ResponseWriter, r *http.Request, host, path string) { + return func(w http.ResponseWriter, r *http.Request, host, path string) { + switch { + case host == tenantAPI && path == "/auth/cli/config": + authConfig(w, routerAPI) + case host == routerAPI && path == "/auth/cli/session": + var body struct{ Port int } + _ = json.NewDecoder(r.Body).Decode(&body) + _ = json.NewEncoder(w).Encode(cliSessionResponse{ + SessionID: "sid", + AuthURL: "http://127.0.0.1:" + strconv.Itoa(body.Port) + "/callback?session_id=sid&redemption_secret=" + strings.Repeat("ab", 32), + }) + case host == routerAPI && path == "/auth/cli/token": + var body cliCallback + _ = json.NewDecoder(r.Body).Decode(&body) + if body.SessionID != "sid" || body.RedemptionSecret != strings.Repeat("ab", 32) { + t.Errorf("redeem body = %+v, want the callback's session and secret", body) + } + _ = json.NewEncoder(w).Encode(cliTokenResponse{ + AccessToken: "router-access", + RefreshToken: "router-refresh", + ExpiresIn: 3600, + QueryParameters: map[string]string{"githubOwnerId": "1"}, + }) + case host == routerAPI && path == "/admin/installations": + _, _ = w.Write([]byte(`{"installations":[{"name":"acme-web","provider":{"id":"GitHub","github":{}},` + + `"nullifyBaseUrl":"https://` + tenantAPI + `","queryParameters":{"githubOwnerId":"1001001"}}]}`)) + default: + t.Errorf("unexpected call to %s%s", host, path) + w.WriteHeader(http.StatusTeapot) + } + } +} + +// The browser's job in a login is to hit the local callback; the test plays +// that part by fetching the auth URL the session returned. +func stubBrowser(t *testing.T) { + t.Helper() + var wg sync.WaitGroup + old := openBrowser + openBrowser = func(url string) error { + wg.Add(1) + go func() { + defer wg.Done() + // Login may close its callback server before this response is + // read; only a callback that never arrived fails the test, and + // it does so by Login timing out. + if resp, err := http.Get(url); err == nil { + resp.Body.Close() + } + }() + return nil + } + t.Cleanup(func() { + wg.Wait() + openBrowser = old + }) +} + +func TestLoginRunsSessionAndRedeemOnTheAuthHost(t *testing.T) { + calls := newRoutedAuthServer(t, fakeRouterLogin(t)) + stubBrowser(t) + // A login the tenant ran itself before it moved to the router. + seed(t, tenantHost, "tenant-pool-token", "tenant-pool-refresh", time.Now().Add(time.Hour).Unix()) + + if err := Login(context.Background(), tenantHost, InstallationPicker{}); err != nil { + t.Fatalf("Login: %v", err) + } + + want := []hostCall{ + {tenantAPI, "/auth/cli/config"}, + {routerAPI, "/auth/cli/session"}, + {routerAPI, "/auth/cli/token"}, + {routerAPI, "/admin/installations"}, + } + if len(*calls) != len(want) { + t.Fatalf("calls = %v, want %v", *calls, want) + } + for i := range want { + if (*calls)[i] != want[i] { + t.Errorf("call %d = %v, want %v", i, (*calls)[i], want[i]) + } + } + + creds, err := LoadCredentials() + if err != nil { + t.Fatalf("LoadCredentials: %v", err) + } + if _, ok := creds[CredentialKey(tenantHost)]; ok { + t.Error("the tenant-key login survived a router login") + } + if got := creds[CredentialKey(routerAPI)]; got.AccessToken != "router-access" || got.RefreshToken != "router-refresh" { + t.Errorf("router credentials = %+v, want the redeemed tokens", got) + } + + authHost, err := AuthHostFor(tenantHost) + if err != nil || authHost != routerAPI { + t.Errorf("AuthHostFor = %q, %v; want %q", authHost, err, routerAPI) + } + // The router is the default host and the tenant its selected data host, + // which is also how CLI v0.26.0 reads a router login. + cfg, err := LoadConfig() + if err != nil || cfg.Host != RouterHost { + t.Errorf("config = %+v, %v; want host %q", cfg, err, RouterHost) + } + router := creds[RouterHost] + if router.DataHost != CredentialKey(tenantHost) || router.Installation != "acme-web" || + router.QueryParameters["githubOwnerId"] != "1001001" { + t.Errorf("router selection = %+v, want acme-web on %s", router, tenantHost) + } + hosts, err := LoadHosts() + if err != nil || hosts[CredentialKey(tenantHost)].Org == nil || hosts[CredentialKey(tenantHost)].Org.ID != "1001001" { + t.Errorf("hosts = %+v, %v; want acme-web remembered for %s", hosts, err, tenantHost) + } +} + +func TestLoginFailsClosedWhenDiscoveryFails(t *testing.T) { + calls := newRoutedAuthServer(t, func(w http.ResponseWriter, _ *http.Request, _, _ string) { + w.WriteHeader(http.StatusBadGateway) + }) + stubBrowser(t) + + err := Login(context.Background(), tenantHost, InstallationPicker{}) + if err == nil || !strings.Contains(err.Error(), "status 502") { + t.Fatalf("err = %v, want the discovery failure", err) + } + if len(*calls) != 1 { + t.Errorf("calls = %v, want only the config lookup: no session on any host", *calls) + } +} + +// bindRouter records a router login for the tenant, as Login would. +func bindRouter(t *testing.T, access string, expiresAt int64) { + t.Helper() + seed(t, routerAPI, access, "router-refresh", expiresAt) + err := UpdateHostConfig(tenantHost, func(hc HostConfig) HostConfig { + hc.AuthHost = routerAPI + return hc + }) + if err != nil { + t.Fatalf("bind: %v", err) + } +} + +func routedRefresh(t *testing.T) func(w http.ResponseWriter, r *http.Request, host, path string) { + return func(w http.ResponseWriter, r *http.Request, host, path string) { + if path != "/auth/refresh_token" { + t.Errorf("unexpected call to %s%s", host, path) + } + refreshHandler(w, r) + } +} + +func TestGetValidTokenRefreshesAtTheAuthHost(t *testing.T) { + calls := newRoutedAuthServer(t, routedRefresh(t)) + bindRouter(t, "expired", time.Now().Add(-time.Hour).Unix()) + + got, err := GetValidToken(context.Background(), tenantHost) + if err != nil { + t.Fatalf("GetValidToken: %v", err) + } + if got != "new-id-token" { + t.Errorf("token = %q, want new-id-token", got) + } + if len(*calls) != 1 || (*calls)[0].host != routerAPI { + t.Errorf("calls = %v, want one refresh on %s", *calls, routerAPI) + } + + creds, _ := LoadCredentials() + if creds[CredentialKey(routerAPI)].AccessToken != "new-id-token" { + t.Error("refreshed token was not saved under the router's key") + } + if _, ok := creds[CredentialKey(tenantHost)]; ok { + t.Error("refresh wrote a credential under the tenant's own key") + } +} + +func TestForceRefreshTokenRefreshesAtTheAuthHost(t *testing.T) { + calls := newRoutedAuthServer(t, routedRefresh(t)) + bindRouter(t, "rejected", time.Now().Add(time.Hour).Unix()) + + got, err := ForceRefreshToken(context.Background(), tenantHost) + if err != nil { + t.Fatalf("ForceRefreshToken: %v", err) + } + if got != "new-id-token" { + t.Errorf("token = %q, want new-id-token", got) + } + if len(*calls) != 1 || (*calls)[0].host != routerAPI { + t.Errorf("calls = %v, want one refresh on %s", *calls, routerAPI) + } +} + +// A credentials.json written before auth hosts existed has the tenant's +// login under the tenant's key and no hosts.json. It must keep working, and +// keep refreshing on the tenant. +func TestLegacyCredentialFileStillWorks(t *testing.T) { + calls := newRoutedAuthServer(t, routedRefresh(t)) + dir := filepath.Join(os.Getenv("HOME"), ".nullify") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + legacy := `{"api.acme.nullify.ai": {"access_token": "legacy", "refresh_token": "legacy-rt", "expires_at": 1,` + + ` "query_parameters": {"githubOwnerId": "7"}}}` + if err := os.WriteFile(filepath.Join(dir, "credentials.json"), []byte(legacy), 0600); err != nil { + t.Fatal(err) + } + + got, err := GetValidToken(context.Background(), tenantHost) + if err != nil { + t.Fatalf("GetValidToken: %v", err) + } + if got != "new-id-token" { + t.Errorf("token = %q, want the refreshed new-id-token", got) + } + if len(*calls) != 1 || (*calls)[0].host != tenantAPI { + t.Errorf("calls = %v, want one refresh on the tenant itself", *calls) + } + + hostCreds, authHost, ok, err := LookupCredentials(tenantHost) + if err != nil || !ok { + t.Fatalf("LookupCredentials: ok=%v err=%v", ok, err) + } + if CredentialKey(authHost) != CredentialKey(tenantHost) { + t.Errorf("auth host = %q, want the tenant itself", authHost) + } + if hostCreds.QueryParameters["githubOwnerId"] != "7" { + t.Errorf("legacy scope = %v, want it kept as stored", hostCreds.QueryParameters) + } +} + +func TestLogoutRemovesTheAuthHostLogin(t *testing.T) { + newRoutedAuthServer(t, func(http.ResponseWriter, *http.Request, string, string) {}) + bindRouter(t, "live", time.Now().Add(time.Hour).Unix()) + seed(t, tenantHost, "legacy", "", 0) + + if err := Logout(tenantHost); err != nil { + t.Fatalf("Logout: %v", err) + } + + creds, err := LoadCredentials() + if err != nil { + t.Fatalf("LoadCredentials: %v", err) + } + if len(creds) != 0 { + t.Errorf("credentials left after logout: %v", creds) + } + if _, err := GetValidToken(context.Background(), tenantHost); err == nil { + t.Error("GetValidToken succeeded after logout") + } +} + +func TestKnownHostsDropsHostsAfterLogout(t *testing.T) { + newRoutedAuthServer(t, func(http.ResponseWriter, *http.Request, string, string) {}) + bindRouter(t, "live", time.Now().Add(time.Hour).Unix()) + if err := UpdateHostConfig("other.nullify.ai", func(hc HostConfig) HostConfig { + hc.AuthHost = routerAPI + return hc + }); err != nil { + t.Fatal(err) + } + + if err := Logout(tenantHost); err != nil { + t.Fatalf("Logout: %v", err) + } + got, err := KnownHosts() + if err != nil { + t.Fatalf("KnownHosts: %v", err) + } + if len(got) != 0 { + t.Errorf("KnownHosts = %v after the shared router login was removed, want none", got) + } + if _, err := GetValidToken(context.Background(), "other.nullify.ai"); err == nil { + t.Error("the other tenant on the same router login is still authenticated") + } +} + +func TestKnownHostsListsTenantsNotTheRouter(t *testing.T) { + newRoutedAuthServer(t, func(http.ResponseWriter, *http.Request, string, string) {}) + bindRouter(t, "live", time.Now().Add(time.Hour).Unix()) + seed(t, "legacy.nullify.ai", "tok", "", 0) + + got, err := KnownHosts() + if err != nil { + t.Fatalf("KnownHosts: %v", err) + } + want := []string{"acme.nullify.ai", "legacy.nullify.ai"} + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("KnownHosts = %v, want %v", got, want) + } +} + +// Redeem and refresh carry the redemption secret and the refresh cookie, so +// a redirect from the auth host must end the call, not be followed. +func TestAuthCallsDoNotFollowRedirects(t *testing.T) { + redirect := func(w http.ResponseWriter, r *http.Request, host, path string) { + if host == "api.evil.example" { + t.Errorf("followed a redirect to %s%s", host, path) + } + w.Header().Set("Location", "https://api.evil.example"+path) + w.WriteHeader(http.StatusTemporaryRedirect) + } + + t.Run("redeem", func(t *testing.T) { + calls := newRoutedAuthServer(t, redirect) + _, err := fetchCLIToken(context.Background(), routerAPI, "sid", strings.Repeat("ab", 32)) + if err == nil || !strings.Contains(err.Error(), "status 307") { + t.Fatalf("err = %v, want the 307 surfaced", err) + } + if len(*calls) != 1 { + t.Errorf("calls = %v, want one", *calls) + } + }) + t.Run("session", func(t *testing.T) { + calls := newRoutedAuthServer(t, redirect) + if _, err := createCLISession(context.Background(), routerAPI, 4242); err == nil { + t.Fatal("want an error on a redirected session") + } + if len(*calls) != 1 { + t.Errorf("calls = %v, want one", *calls) + } + }) + t.Run("refresh", func(t *testing.T) { + calls := newRoutedAuthServer(t, redirect) + if _, err := refreshToken(context.Background(), routerAPI, HostCredentials{RefreshToken: "rt"}); err == nil { + t.Fatal("want an error on a redirected refresh") + } + if len(*calls) != 1 { + t.Errorf("calls = %v, want one", *calls) + } + }) +} + +func TestLoginOnALegacyStackStaysOnTheTenant(t *testing.T) { + calls := newRoutedAuthServer(t, func(w http.ResponseWriter, r *http.Request, host, path string) { + if host != tenantAPI { + t.Errorf("call left the tenant: %s%s", host, path) + } + switch path { + case "/auth/cli/config": + http.NotFound(w, r) + case "/auth/cli/session": + var body struct{ Port int } + _ = json.NewDecoder(r.Body).Decode(&body) + _ = json.NewEncoder(w).Encode(cliSessionResponse{ + SessionID: "sid", + AuthURL: "http://127.0.0.1:" + strconv.Itoa(body.Port) + "/callback?session_id=sid&redemption_secret=" + strings.Repeat("ab", 32), + }) + case "/auth/cli/token": + _ = json.NewEncoder(w).Encode(cliTokenResponse{AccessToken: "tenant-access", RefreshToken: "tenant-refresh", ExpiresIn: 3600}) + } + }) + stubBrowser(t) + + if err := Login(context.Background(), tenantHost, InstallationPicker{}); err != nil { + t.Fatalf("Login: %v", err) + } + if len(*calls) != 3 { + t.Errorf("calls = %v, want config, session and redeem", *calls) + } + creds, _ := LoadCredentials() + if creds[CredentialKey(tenantHost)].AccessToken != "tenant-access" { + t.Errorf("credentials = %v, want the login under the tenant's own key", creds) + } + if authHost, _ := AuthHostFor(tenantHost); authHost != tenantAPI { + t.Errorf("auth host = %q, want the tenant", authHost) + } +} + +func TestCorruptHostsFileIsReportedNotTreatedAsLoggedOut(t *testing.T) { + newRoutedAuthServer(t, func(http.ResponseWriter, *http.Request, string, string) {}) + seed(t, tenantHost, "tok", "", time.Now().Add(time.Hour).Unix()) + if err := os.WriteFile(filepath.Join(os.Getenv("HOME"), ".nullify", "hosts.json"), []byte("{not json"), 0600); err != nil { + t.Fatal(err) + } + + _, err := GetValidToken(context.Background(), tenantHost) + if err == nil || !strings.Contains(err.Error(), "hosts.json") { + t.Fatalf("err = %v, want the hosts.json problem named", err) + } +} + +// A login with an unreadable hosts file fails before it opens the browser or +// saves anything, so it never leaves credentials the hosts file cannot bind. +func TestLoginWithACorruptHostsFileSavesNothing(t *testing.T) { + calls := newRoutedAuthServer(t, func(http.ResponseWriter, *http.Request, string, string) {}) + if err := os.MkdirAll(filepath.Join(os.Getenv("HOME"), ".nullify"), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(os.Getenv("HOME"), ".nullify", "hosts.json"), []byte("{not json"), 0600); err != nil { + t.Fatal(err) + } + + err := Login(context.Background(), tenantHost, InstallationPicker{}) + if err == nil || !strings.Contains(err.Error(), "hosts.json") { + t.Fatalf("err = %v, want the hosts.json problem named", err) + } + if len(*calls) != 0 { + t.Errorf("calls = %v, want none", *calls) + } + if _, err := LoadCredentials(); !os.IsNotExist(err) { + t.Errorf("credentials were written: %v", err) + } +} diff --git a/internal/auth/credentials.go b/internal/auth/credentials.go index 0483147..f0cc58a 100644 --- a/internal/auth/credentials.go +++ b/internal/auth/credentials.go @@ -3,6 +3,7 @@ package auth import ( "encoding/json" "fmt" + "maps" "os" "path/filepath" "strings" @@ -117,44 +118,46 @@ func CredentialKey(host string) string { } func SaveHostCredentials(host string, hostCreds HostCredentials) error { - creds, err := LoadCredentials() - if err != nil { - if !os.IsNotExist(err) { - return fmt.Errorf("cannot update credentials: %w", err) - } - creds = make(Credentials) - } - - creds[CredentialKey(host)] = hostCreds - - return SaveCredentials(creds) + return updateHostCredentials(host, func(HostCredentials) HostCredentials { return hostCreds }) } func updateHostCredentials(host string, update func(HostCredentials) HostCredentials) error { - creds, err := LoadCredentials() - if err != nil { - if !os.IsNotExist(err) { - return fmt.Errorf("cannot update credentials: %w", err) - } - creds = make(Credentials) - } - - key := CredentialKey(host) - creds[key] = update(creds[key]) + return updateCredentials(func(creds Credentials) Credentials { + next := maps.Clone(creds) + key := CredentialKey(host) + next[key] = update(creds[key]) + return next + }) +} - return SaveCredentials(creds) +// updateCredentials applies update to the stored credentials under the store +// lock and saves them. +func updateCredentials(update func(Credentials) Credentials) error { + return withStoreLock(func() error { + creds, err := LoadCredentials() + if err != nil { + if !os.IsNotExist(err) { + return fmt.Errorf("cannot update credentials: %w", err) + } + creds = make(Credentials) + } + return SaveCredentials(update(creds)) + }) } func DeleteHostCredentials(host string) error { - creds, err := LoadCredentials() - if err != nil { - if os.IsNotExist(err) { - return nil + return withStoreLock(func() error { + creds, err := LoadCredentials() + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err } - return err - } - delete(creds, CredentialKey(host)) + next := maps.Clone(creds) + delete(next, CredentialKey(host)) - return SaveCredentials(creds) + return SaveCredentials(next) + }) } diff --git a/internal/auth/hosts.go b/internal/auth/hosts.go new file mode 100644 index 0000000..f0abce5 --- /dev/null +++ b/internal/auth/hosts.go @@ -0,0 +1,244 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "os" + "path/filepath" + "slices" +) + +// HostConfig is what the CLI remembers about one API host. AuthHost is the +// host that ran its login and serves its refresh, which on a router tenant is +// the router rather than the tenant itself. Org is the org the user picked +// for this host, if any. +type HostConfig struct { + AuthHost string `json:"auth_host,omitempty"` + Org *OrgPick `json:"org,omitempty"` +} + +// Hosts maps an API host, in CredentialKey form, to its HostConfig. It lives +// apart from credentials.json because it holds no secrets, and apart from +// config.json because callers rewrite that file whole. +type Hosts map[string]HostConfig + +func hostsPath() (string, error) { + dir, err := configDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "hosts.json"), nil +} + +// LoadHosts returns the stored host bindings, empty when none were saved. +func LoadHosts() (Hosts, error) { + path, err := hostsPath() + if err != nil { + return nil, err + } + + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return Hosts{}, nil + } + if err != nil { + return nil, err + } + + var hosts Hosts + if err := json.Unmarshal(data, &hosts); err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + if hosts == nil { + hosts = Hosts{} + } + return hosts, nil +} + +func saveHosts(hosts Hosts) error { + if err := ensureConfigDir(); err != nil { + return err + } + + path, err := hostsPath() + if err != nil { + return err + } + + data, err := json.MarshalIndent(hosts, "", " ") + if err != nil { + return err + } + + return atomicWriteFile(path, data, 0600) +} + +// UpdateHostConfig applies update to the stored config for host and saves it. +func UpdateHostConfig(host string, update func(HostConfig) HostConfig) error { + return updateHosts(func(hosts Hosts) Hosts { + next := maps.Clone(hosts) + next[CredentialKey(host)] = update(hosts[CredentialKey(host)]) + return next + }) +} + +// updateHosts applies update to the stored host configs under the store lock +// and saves them. +func updateHosts(update func(Hosts) Hosts) error { + return withStoreLock(func() error { + hosts, err := LoadHosts() + if err != nil { + return err + } + return saveHosts(update(hosts)) + }) +} + +// AuthHostFor returns the host whose credentials serve host: the auth host +// recorded at login, or host itself for a login made before the CLI asked +// (those credentials are stored under the tenant's own key). +func AuthHostFor(host string) (string, error) { + hosts, err := LoadHosts() + if err != nil { + return "", err + } + if hc, ok := hosts[CredentialKey(host)]; ok && hc.AuthHost != "" { + return hc.AuthHost, nil + } + return host, nil +} + +// LookupCredentials returns the stored credentials that serve host and the +// auth host they belong to. ok is false when none are stored. +func LookupCredentials(host string) (hostCreds HostCredentials, authHost string, ok bool, err error) { + authHost, err = AuthHostFor(host) + if err != nil { + return HostCredentials{}, "", false, err + } + + creds, err := LoadCredentials() + if err != nil { + return HostCredentials{}, authHost, false, err + } + + hostCreds, ok = creds[CredentialKey(authHost)] + return hostCreds, authHost, ok, nil +} + +// KnownHosts lists the API hosts the user is signed in to, sorted. Router +// credentials are shared by the tenants bound to them, so the router's own key +// is listed only when no tenant is. +func KnownHosts() ([]string, error) { + hosts, err := LoadHosts() + if err != nil { + return nil, err + } + creds, err := LoadCredentials() + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, err + } + + known := map[string]bool{} + for h := range creds { + known[h] = true + } + for h, hc := range hosts { + if authKey := CredentialKey(hc.AuthHost); authKey != h { + delete(known, authKey) + } + } + // A bound host is known while the login behind it is: a logout removes + // that login and, with it, every host signed in through it. + for h, hc := range hosts { + if _, ok := creds[CredentialKey(hc.AuthHost)]; ok && hc.AuthHost != "" { + known[h] = true + } + } + + return slices.Sorted(maps.Keys(known)), nil +} + +// StoredPick returns the org remembered for dataHost: the one saved by a +// login or 'nullify org use', else the installation a CLI v0.26.0 router +// login selected there, which this version takes over as the same choice. +// It is returned even when the org is no longer available, so the caller can +// say so instead of quietly using another. +func StoredPick(dataHost string) *OrgPick { + key := CredentialKey(dataHost) + if hosts, err := LoadHosts(); err == nil && hosts[key].Org != nil { + return hosts[key].Org + } + + creds, err := LoadCredentials() + if err != nil { + return nil + } + for _, r := range routers { + rc := creds[r.Key] + if rc.DataHost != key { + continue + } + for _, k := range slices.Sorted(maps.Keys(rc.QueryParameters)) { + if slices.Contains(slices.Collect(maps.Values(providerKeys)), k) && rc.QueryParameters[k] != "" { + return &OrgPick{Name: rc.Installation, Key: k, ID: rc.QueryParameters[k]} + } + } + } + return nil +} + +// CoverTenant binds tenant to a stored router login that lists one of the +// user's orgs on it, and reports whether one did: a router login covers every +// tenant it lists, including one added after the login. +func CoverTenant(ctx context.Context, tenant string) (bool, error) { + if covered, err := CoverSelectedTenant(tenant); covered || err != nil { + return covered, err + } + creds, err := LoadCredentials() + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + + var checkErr error + for _, r := range routers { + if _, ok := creds[r.Key]; !ok { + continue + } + // A router only serves tenants under its own domain. + if _, ok := r.tenantLabel(apiHost(CredentialKey(tenant))); !ok { + continue + } + token, err := GetValidToken(ctx, r.Key) + if err == nil { + var orgs []Org + if orgs, err = fetchOrgs(ctx, r.AuthHost, token); err == nil && len(orgsOn(routedOrgs(orgs), tenant)) > 0 { + return true, BindTenant(tenant, r) + } + } + if err != nil { + checkErr = fmt.Errorf("could not check whether your %s login covers %s: %w", r.Key, tenant, err) + } + } + return false, checkErr +} + +// CoverSelectedTenant binds tenant to the router login whose selected data +// host it is, as a CLI v0.26.0 login leaves it, without asking the router. +func CoverSelectedTenant(tenant string) (bool, error) { + creds, err := LoadCredentials() + if err != nil { + return false, nil + } + for _, r := range routers { + if rc, ok := creds[r.Key]; ok && rc.DataHost == CredentialKey(tenant) { + return true, BindTenant(tenant, r) + } + } + return false, nil +} diff --git a/internal/auth/installations.go b/internal/auth/installations.go index 317fd1c..3897f85 100644 --- a/internal/auth/installations.go +++ b/internal/auth/installations.go @@ -2,150 +2,116 @@ package auth import ( "bufio" - "context" "errors" "fmt" "io" - "maps" "strconv" "strings" - - "github.com/nullify-platform/cli/internal/api" - "github.com/nullify-platform/cli/internal/api/models" - "github.com/nullify-platform/cli/internal/logger" ) -var errNoUsableInstallation = errors.New("no installation is ready for CLI access through nullify.ai - run 'nullify auth login --host .nullify.ai' with your Nullify instance") - -// Installation is a tenant installation that the router can route the user to. -type Installation struct { - Name string - DataHost string - QueryParameters map[string]string -} +var errNoUsableInstallation = errors.New("none of your organizations is ready for CLI access through the Nullify router - if your instance signs in itself, run 'nullify auth login --host .nullify.ai'") -// InstallationPicker selects one installation by name, by prompt, or automatically when only one exists. +// InstallationPicker selects the org a router login starts with: by name or +// id, the one remembered for its host, by prompt, or the only one. type InstallationPicker struct { Name string Interactive bool In io.Reader Out io.Writer + // Remembered is the org last chosen, used when it is still offered. + Remembered *OrgPick } -func fetchInstallations(ctx context.Context, token string) ([]Installation, error) { - client := api.NewClient(RouterHost, token, nil, api.WithHTTPClient(httpClient)) - client.BaseURL = authBaseURL(RouterHost) - - out, err := client.ListAdminInstallations(ctx, api.ListAdminInstallationsInput{}) - if err != nil { - return nil, fmt.Errorf("list installations: %w", err) +// routedOrgs returns the orgs a router serves from a usable data host. +func routedOrgs(orgs []Org) []Org { + routed := make([]Org, 0, len(orgs)) + for _, o := range orgs { + if o.Host != "" && o.Status != "pending" && o.Status != "unavailable" { + routed = append(routed, o) + } } - return usableInstallations(ctx, out.Installations), nil + return routed } -func usableInstallations(ctx context.Context, outputs []models.EndpointsAdminInstallationOutput) []Installation { - installs := make([]Installation, 0, len(outputs)) - for _, out := range outputs { - if out.NullifyBaseURL == nil { - logger.L(ctx).Debug("skipping installation without a base url", logger.String("installation", out.Name)) - continue +func orgsOn(orgs []Org, dataHost string) []Org { + on := make([]Org, 0, len(orgs)) + for _, o := range orgs { + if o.DataHost() == CredentialKey(dataHost) { + on = append(on, o) } - if len(out.QueryParameters) == 0 { - logger.L(ctx).Debug("skipping installation without query parameters", logger.String("installation", out.Name)) - continue - } - - dataHost, err := dataHostFromBaseURL(*out.NullifyBaseURL) - if err != nil { - logger.L(ctx).Debug("skipping installation with invalid base url", logger.String("installation", out.Name), logger.Err(err)) - continue - } - - installs = append(installs, Installation{ - Name: out.Name, - DataHost: dataHost, - QueryParameters: maps.Clone(out.QueryParameters), - }) } - return installs + return on } -// Pick returns the selected installation from installs. -func (p InstallationPicker) Pick(installs []Installation) (Installation, error) { - if len(installs) == 0 { - return Installation{}, errNoUsableInstallation +// Pick returns the selected org from orgs. +func (p InstallationPicker) Pick(orgs []Org) (Org, error) { + if len(orgs) == 0 { + return Org{}, errNoUsableInstallation } if p.Name != "" { - return findInstallation(installs, p.Name) + return findInstallation(orgs, p.Name) } - if len(installs) == 1 { - return installs[0], nil + if p.Remembered != nil { + for _, o := range orgs { + if o.Key == p.Remembered.Key && o.ID == p.Remembered.ID { + return o, nil + } + } + } + + if len(orgs) == 1 { + return orgs[0], nil } if !p.Interactive { - return Installation{}, fmt.Errorf("multiple installations are available (%s) - pass --installation ", installationNames(installs)) + return Org{}, fmt.Errorf("multiple organizations are available (%s) - pass --org ", installationNames(orgs)) } - return p.prompt(installs) + return p.prompt(orgs) } -func findInstallation(installs []Installation, name string) (Installation, error) { - var matches []Installation - for _, install := range installs { - if install.Name == name { - matches = append(matches, install) +func findInstallation(orgs []Org, name string) (Org, error) { + var matches []Org + for _, o := range orgs { + if o.Matches(name) { + matches = append(matches, o) } } switch len(matches) { case 0: - return Installation{}, fmt.Errorf("installation %q not found - available: %s", name, installationNames(installs)) + return Org{}, fmt.Errorf("organization %q not found - available: %s", name, installationNames(orgs)) case 1: return matches[0], nil default: - return Installation{}, fmt.Errorf("installation name %q matches %d installations - run 'nullify auth login --host .nullify.ai'", name, len(matches)) + return Org{}, fmt.Errorf("organization name %q matches %d organizations - use its id", name, len(matches)) } } -func (p InstallationPicker) prompt(installs []Installation) (Installation, error) { - if err := ensureUniqueNames(installs); err != nil { - return Installation{}, err - } - - fmt.Fprintln(p.Out, "Select an installation:") - for i, install := range installs { - fmt.Fprintf(p.Out, " %d) %s (%s)\n", i+1, install.Name, install.DataHost) +func (p InstallationPicker) prompt(orgs []Org) (Org, error) { + fmt.Fprintln(p.Out, "Select an organization:") + for i, o := range orgs { + fmt.Fprintf(p.Out, " %d) %s on %s\n", i+1, o, o.DataHost()) } - fmt.Fprintf(p.Out, "Enter a number [1-%d]: ", len(installs)) + fmt.Fprintf(p.Out, "Enter a number [1-%d]: ", len(orgs)) line, err := bufio.NewReader(p.In).ReadString('\n') if err != nil && !errors.Is(err, io.EOF) { - return Installation{}, fmt.Errorf("read selection: %w", err) + return Org{}, fmt.Errorf("read selection: %w", err) } choice, err := strconv.Atoi(strings.TrimSpace(line)) - if err != nil || choice < 1 || choice > len(installs) { - return Installation{}, fmt.Errorf("invalid selection %q - enter a number from 1 to %d", strings.TrimSpace(line), len(installs)) - } - return installs[choice-1], nil -} - -func ensureUniqueNames(installs []Installation) error { - seen := make(map[string]bool, len(installs)) - for _, install := range installs { - if seen[install.Name] { - return fmt.Errorf("installation name %q is used by more than one installation - run 'nullify auth login --host .nullify.ai'", install.Name) - } - seen[install.Name] = true + if err != nil || choice < 1 || choice > len(orgs) { + return Org{}, fmt.Errorf("invalid selection %q - enter a number from 1 to %d", strings.TrimSpace(line), len(orgs)) } - return nil + return orgs[choice-1], nil } -func installationNames(installs []Installation) string { - names := make([]string, 0, len(installs)) - for _, install := range installs { - names = append(names, install.Name) +func installationNames(orgs []Org) string { + names := make([]string, 0, len(orgs)) + for _, o := range orgs { + names = append(names, o.Name) } return strings.Join(names, ", ") } diff --git a/internal/auth/installations_test.go b/internal/auth/installations_test.go index 79713bc..3b8e19e 100644 --- a/internal/auth/installations_test.go +++ b/internal/auth/installations_test.go @@ -4,60 +4,84 @@ import ( "bytes" "context" "errors" + "fmt" "net/http" "strings" "testing" - "github.com/nullify-platform/cli/internal/api/models" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestUsableInstallations(t *testing.T) { - params := map[string]string{"githubOwnerId": "1"} - outputs := []models.EndpointsAdminInstallationOutput{ - {Name: "ready", NullifyBaseURL: new("https://api.acme.nullify.ai"), QueryParameters: params}, - {Name: "pending", QueryParameters: params}, - {Name: "no-params", NullifyBaseURL: new("https://api.beta.nullify.ai")}, - {Name: "bad-url", NullifyBaseURL: new("https://api.acme.evil.com"), QueryParameters: params}, - {Name: "router-url", NullifyBaseURL: new("https://api.nullify.ai"), QueryParameters: params}, - } +func installationsBody(entries ...string) string { + return `{"installations":[` + strings.Join(entries, ",") + `]}` +} - got := usableInstallations(context.Background(), outputs) +func entry(name, baseURL, id string) string { + if baseURL == "" { + return fmt.Sprintf(`{"name":%q,"provider":{},"queryParameters":{"githubOwnerId":%q}}`, name, id) + } + return fmt.Sprintf(`{"name":%q,"provider":{},"nullifyBaseUrl":%q,"queryParameters":{"githubOwnerId":%q}}`, name, baseURL, id) +} - require.Equal(t, []Installation{ - {Name: "ready", DataHost: "acme.nullify.ai", QueryParameters: params}, - }, got) +// A router's org is used only from one of that router's own tenant origins: +// a listing naming any other host, the other router environment's tenants +// included, leaves the org unavailable and the host is never contacted. +func TestRoutedOrgsKeepOnlyTheRoutersTenantOrigins(t *testing.T) { + body := installationsBody( + entry("ready", "https://api.acme.nullify.ai", "1"), + entry("pending", "", "2"), + entry("evil", "https://api.acme.evil.com", "3"), + entry("fragment", "https://api.attacker.example#.nullify.ai", "4"), + entry("router", "https://api.nullify.ai", "5"), + entry("dev", "https://api.sandbox.dev.nullify.ai", "6"), + entry("port", "https://api.acme.nullify.ai:8443", "7"), + ) + newAuthServer(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }) + + prod, err := fetchOrgs(context.Background(), "api.nullify.ai", "router-token") + require.NoError(t, err) + routed := routedOrgs(prod) + require.Len(t, routed, 1) + require.Equal(t, "ready", routed[0].Name) + require.Equal(t, "acme.nullify.ai", routed[0].DataHost()) + require.Equal(t, map[string]string{"githubOwnerId": "1"}, routed[0].Query()) - got[0].QueryParameters["githubOwnerId"] = "2" - require.Equal(t, "1", params["githubOwnerId"], "installation must not share the response map") + dev, err := fetchOrgs(context.Background(), "api.router.dev.nullify.ai", "router-token") + require.NoError(t, err) + routed = routedOrgs(dev) + require.Len(t, routed, 1) + require.Equal(t, "dev", routed[0].Name) + require.Equal(t, "sandbox.dev.nullify.ai", routed[0].DataHost()) } func TestInstallationPickerPick(t *testing.T) { - acme := Installation{Name: "acme", DataHost: "acme.nullify.ai"} - beta := Installation{Name: "beta", DataHost: "beta.nullify.ai"} - acmeGitLab := Installation{Name: "acme", DataHost: "acme-gl.nullify.ai"} + acme := Org{Name: "acme", Key: "githubOwnerId", ID: "1", Host: "api.acme.nullify.ai"} + beta := Org{Name: "beta", Key: "githubOwnerId", ID: "2", Host: "api.beta.nullify.ai"} + acmeElsewhere := Org{Name: "acme", Key: "githubOwnerId", ID: "3", Host: "api.acme-gl.nullify.ai"} tests := []struct { name string picker InstallationPicker - installs []Installation - want Installation + orgs []Org + want Org wantErr string wantErrIs error wantPrompts bool }{ - {name: "none", installs: nil, wantErrIs: errNoUsableInstallation}, - {name: "single selects automatically", installs: []Installation{acme}, want: acme}, - {name: "single still honours name", picker: InstallationPicker{Name: "beta"}, installs: []Installation{acme}, wantErr: `installation "beta" not found - available: acme`}, - {name: "by name", picker: InstallationPicker{Name: "beta"}, installs: []Installation{acme, beta}, want: beta}, - {name: "duplicate name", picker: InstallationPicker{Name: "acme"}, installs: []Installation{acme, acmeGitLab}, wantErr: "matches 2 installations"}, - {name: "many without terminal", installs: []Installation{acme, beta}, wantErr: "multiple installations are available (acme, beta) - pass --installation "}, - {name: "prompt", picker: InstallationPicker{Interactive: true, In: strings.NewReader("2\n")}, installs: []Installation{acme, beta}, want: beta, wantPrompts: true}, - {name: "prompt without newline", picker: InstallationPicker{Interactive: true, In: strings.NewReader("1")}, installs: []Installation{acme, beta}, want: acme, wantPrompts: true}, - {name: "prompt out of range", picker: InstallationPicker{Interactive: true, In: strings.NewReader("3\n")}, installs: []Installation{acme, beta}, wantErr: "invalid selection", wantPrompts: true}, - {name: "prompt not a number", picker: InstallationPicker{Interactive: true, In: strings.NewReader("acme\n")}, installs: []Installation{acme, beta}, wantErr: "invalid selection", wantPrompts: true}, - {name: "prompt with duplicate names", picker: InstallationPicker{Interactive: true, In: strings.NewReader("1\n")}, installs: []Installation{acme, acmeGitLab}, wantErr: "used by more than one installation"}, + {name: "none", orgs: nil, wantErrIs: errNoUsableInstallation}, + {name: "single selects automatically", orgs: []Org{acme}, want: acme}, + {name: "single still honours name", picker: InstallationPicker{Name: "beta"}, orgs: []Org{acme}, wantErr: `organization "beta" not found - available: acme`}, + {name: "by name", picker: InstallationPicker{Name: "beta"}, orgs: []Org{acme, beta}, want: beta}, + {name: "by id", picker: InstallationPicker{Name: "2"}, orgs: []Org{acme, beta}, want: beta}, + {name: "duplicate name", picker: InstallationPicker{Name: "acme"}, orgs: []Org{acme, acmeElsewhere}, wantErr: "matches 2 organizations"}, + {name: "remembered", picker: InstallationPicker{Remembered: &OrgPick{Key: "githubOwnerId", ID: "2"}}, orgs: []Org{acme, beta}, want: beta}, + {name: "remembered but gone", picker: InstallationPicker{Remembered: &OrgPick{Key: "githubOwnerId", ID: "9"}}, orgs: []Org{acme, beta}, wantErr: "multiple organizations are available"}, + {name: "many without terminal", orgs: []Org{acme, beta}, wantErr: "multiple organizations are available (acme, beta) - pass --org "}, + {name: "prompt", picker: InstallationPicker{Interactive: true, In: strings.NewReader("2\n")}, orgs: []Org{acme, beta}, want: beta, wantPrompts: true}, + {name: "prompt without newline", picker: InstallationPicker{Interactive: true, In: strings.NewReader("1")}, orgs: []Org{acme, beta}, want: acme, wantPrompts: true}, + {name: "prompt out of range", picker: InstallationPicker{Interactive: true, In: strings.NewReader("3\n")}, orgs: []Org{acme, beta}, wantErr: "invalid selection", wantPrompts: true}, + {name: "prompt not a number", picker: InstallationPicker{Interactive: true, In: strings.NewReader("acme\n")}, orgs: []Org{acme, beta}, wantErr: "invalid selection", wantPrompts: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -65,7 +89,7 @@ func TestInstallationPickerPick(t *testing.T) { picker := tt.picker picker.Out = &out - got, err := picker.Pick(tt.installs) + got, err := picker.Pick(tt.orgs) switch { case tt.wantErrIs != nil: @@ -78,8 +102,8 @@ func TestInstallationPickerPick(t *testing.T) { } if tt.wantPrompts { - require.Contains(t, out.String(), "1) acme (acme.nullify.ai)") - require.Contains(t, out.String(), "2) beta (beta.nullify.ai)") + require.Contains(t, out.String(), "1) acme (githubOwnerId 1) on acme.nullify.ai") + require.Contains(t, out.String(), "2) beta (githubOwnerId 2) on beta.nullify.ai") } else { require.Empty(t, out.String()) } @@ -87,30 +111,45 @@ func TestInstallationPickerPick(t *testing.T) { } } -func TestFetchInstallationsSendsBearerToRouter(t *testing.T) { +func TestFetchOrgsSendsBearerToRouter(t *testing.T) { newAuthServer(t, func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/admin/installations", r.URL.Path) assert.Equal(t, "Bearer router-token", r.Header.Get("Authorization")) - _, _ = w.Write([]byte(`{"installations":[{"name":"acme","avatarUrl":"","provider":{},` + - `"nullifyBaseUrl":"https://api.acme.nullify.ai","queryParameters":{"githubOwnerId":"7"}}]}`)) + _, _ = w.Write([]byte(installationsBody(entry("acme", "https://api.acme.nullify.ai", "7")))) }) authHosts := recordAuthHosts(t) - got, err := fetchInstallations(context.Background(), "router-token") + got, err := fetchOrgs(context.Background(), "api.nullify.ai", "router-token") require.NoError(t, err) - requireOnlyRouterHost(t, *authHosts, 1) - require.Equal(t, []Installation{ - {Name: "acme", DataHost: "acme.nullify.ai", QueryParameters: map[string]string{"githubOwnerId": "7"}}, - }, got) + require.Equal(t, []string{"api.nullify.ai"}, *authHosts) + require.Equal(t, []Org{{Name: "acme", Key: "githubOwnerId", ID: "7", Host: "api.acme.nullify.ai"}}, got) } -func TestFetchInstallationsReportsServerError(t *testing.T) { +func TestFetchOrgsReportsServerError(t *testing.T) { newAuthServer(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusForbidden) }) - _, err := fetchInstallations(context.Background(), "router-token") + _, err := fetchOrgs(context.Background(), "api.nullify.ai", "router-token") require.Error(t, err) require.False(t, errors.Is(err, errNoUsableInstallation)) } + +// An org the router reports pending or unavailable is not usable on its host +// even when its base URL names that host. +func TestPendingOrgOnTheHostIsNotUsable(t *testing.T) { + body := `{"installations":[` + + `{"name":"ready","provider":{},"nullifyBaseUrl":"https://api.acme.nullify.ai","queryParameters":{"githubOwnerId":"1"},"provisioningStatus":"ready"},` + + `{"name":"held","provider":{},"nullifyBaseUrl":"https://api.acme.nullify.ai","queryParameters":{"githubOwnerId":"2"},"provisioningStatus":"pending"}]}` + newAuthServer(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }) + + list, err := ListOrgs(context.Background(), "acme.nullify.ai", "api.nullify.ai", "router-token") + require.NoError(t, err) + require.Len(t, list.Orgs, 1) + require.Equal(t, "ready", list.Orgs[0].Name) + require.Len(t, list.Pending, 1) + require.Equal(t, "held", list.Pending[0].Name) + _, err = ResolveOrg("acme.nullify.ai", list, OrgInputs{Flag: "held", FlagSource: "--org"}) + require.ErrorContains(t, err, "not ready for use yet") +} diff --git a/internal/auth/lock.go b/internal/auth/lock.go new file mode 100644 index 0000000..dfcd252 --- /dev/null +++ b/internal/auth/lock.go @@ -0,0 +1,40 @@ +package auth + +import ( + "context" + "fmt" + "path/filepath" + "time" + + "github.com/gofrs/flock" +) + +// storeLockWait bounds how long a write waits for another process's. +var storeLockWait = 30 * time.Second + +// withStoreLock runs fn holding the lock on ~/.nullify, so two processes +// (a terminal command and a running MCP server, say) rewriting credentials +// or hosts at once cannot drop each other's update. fn must not take it again. +func withStoreLock(fn func() error) error { + if err := ensureConfigDir(); err != nil { + return err + } + dir, err := configDir() + if err != nil { + return err + } + + lock := flock.New(filepath.Join(dir, ".lock")) + ctx, cancel := context.WithTimeout(context.Background(), storeLockWait) + defer cancel() + locked, err := lock.TryLockContext(ctx, 20*time.Millisecond) + if err != nil { + return fmt.Errorf("lock %s: %w", dir, err) + } + if !locked { + return fmt.Errorf("lock %s: timed out", dir) + } + defer func() { _ = lock.Unlock() }() + + return fn() +} diff --git a/internal/auth/login.go b/internal/auth/login.go index ae9490e..f573782 100644 --- a/internal/auth/login.go +++ b/internal/auth/login.go @@ -6,12 +6,14 @@ import ( "errors" "fmt" "io" + "maps" "net" "net/http" "os" "os/exec" "runtime" "strings" + "sync" "time" "github.com/nullify-platform/cli/internal/logger" @@ -23,7 +25,13 @@ var httpClient = &http.Client{Timeout: 30 * time.Second} // tests can point the auth calls at an httptest server. var authBaseURL = func(host string) string { return "https://" + apiHost(host) } -var openBrowser = OpenBrowser +// authClient is httpClient without redirects. Session, redeem and refresh +// carry secrets, and a 307 would replay them to wherever it points. +func authClient() *http.Client { + c := *httpClient + c.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + return &c +} type cliSessionResponse struct { SessionID string `json:"session_id"` @@ -38,20 +46,56 @@ type cliTokenResponse struct { QueryParameters map[string]string `json:"query_parameters,omitempty"` } -// Login signs in to host in the browser. For the router host it also selects the installation that serves data. +// Login signs in for host in the browser. A router host signs in at that +// router. A tenant host is asked where its CLI login runs: a tenant on a +// router signs in at the router, which then covers every tenant it lists for +// the user, and picker chooses the org among those on host; any other +// tenant signs in itself. func Login(ctx context.Context, host string, picker InstallationPicker) error { - host = normalizeHost(host) + // The login is saved across credentials and hosts; an unreadable hosts + // file would fail the second write after the first, so it fails first. + if _, err := LoadHosts(); err != nil { + return fmt.Errorf("%w - remove the file and log in again", err) + } + router, onRouter := RouterFor(host) + tenant := "" + authHost := router.AuthHost + if !onRouter { + discovered, err := DiscoverAuthHost(ctx, host) + if err != nil { + return fmt.Errorf("failed to find the login host: %w", err) + } + authHost = discovered + router, onRouter = RouterFor(authHost) + if onRouter { + tenant = CredentialKey(host) + fmt.Printf("%s signs in through %s.\n", host, authHost) + } + } + + tokenResp, err := browserLogin(ctx, authHost) + if err != nil { + return err + } + + if onRouter { + return saveRouterLogin(ctx, router, tenant, tokenResp, picker) + } + return saveTenantLogin(host, tokenResp) +} + +func browserLogin(ctx context.Context, authHost string) (*cliTokenResponse, error) { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { - return fmt.Errorf("failed to start local server: %w", err) + return nil, fmt.Errorf("failed to start local server: %w", err) } port := listener.Addr().(*net.TCPAddr).Port - sessionResp, err := createCLISession(ctx, host, port) + sessionResp, err := createCLISession(ctx, authHost, port) if err != nil { listener.Close() - return fmt.Errorf("failed to create auth session: %w", err) + return nil, fmt.Errorf("failed to create auth session: %w", err) } sessionCh := make(chan cliCallback, 1) @@ -89,98 +133,269 @@ waitLoop: case callback = <-sessionCh: break waitLoop case err := <-errCh: - return fmt.Errorf("local server error: %w", err) + return nil, fmt.Errorf("local server error: %w", err) case <-ctx.Done(): - return fmt.Errorf("authentication cancelled") + return nil, fmt.Errorf("authentication cancelled") case <-timeout: - return fmt.Errorf("authentication timed out — the session has expired") + return nil, fmt.Errorf("authentication timed out — the session has expired") case <-ticker.C: fmt.Println("Still waiting for authentication...") } } - tokenResp, err := fetchCLIToken(ctx, host, callback.SessionID, callback.RedemptionSecret) + tokenResp, err := fetchCLIToken(ctx, authHost, callback.SessionID, callback.RedemptionSecret) if err != nil { - return fmt.Errorf("failed to fetch tokens: %w", err) + return nil, fmt.Errorf("failed to fetch tokens: %w", err) } - if tokenResp.Error != "" { - return fmt.Errorf("authentication failed: %s", tokenResp.Error) + return nil, fmt.Errorf("authentication failed: %s", tokenResp.Error) } + return tokenResp, nil +} - hostCreds := HostCredentials{ +// saveTenantLogin stores a login a tenant ran itself. The query parameters it +// returned are the server's default org, kept only for older CLI versions +// reading this file; this version never takes them as the user's choice. +func saveTenantLogin(host string, tokenResp *cliTokenResponse) error { + err := SaveHostCredentials(host, HostCredentials{ AccessToken: tokenResp.AccessToken, RefreshToken: tokenResp.RefreshToken, ExpiresAt: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Unix(), QueryParameters: tokenResp.QueryParameters, + }) + if err != nil { + return fmt.Errorf("failed to save credentials: %w", err) } - if host == RouterHost { - install, err := selectInstallation(ctx, tokenResp.AccessToken, picker) - if err != nil { - return err + err = UpdateHostConfig(host, func(hc HostConfig) HostConfig { + hc.AuthHost = apiHost(host) + return hc + }) + if err != nil { + return fmt.Errorf("failed to save host config: %w", err) + } + + if err := SaveConfig(&Config{Host: host}); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + fmt.Println("\nAuthenticated successfully!") + return nil +} + +// saveRouterLogin stores a router login and the org it starts with. Every +// tenant the router lists for the user is bound to the router, and a login a +// listed tenant ran itself before is removed: the router's covers it now. +// tenant, when set, limits the starting org to that tenant's. Nothing is +// saved when no org can be selected. +func saveRouterLogin(ctx context.Context, router Router, tenant string, tokenResp *cliTokenResponse, picker InstallationPicker) error { + orgs, err := fetchOrgs(ctx, router.AuthHost, tokenResp.AccessToken) + if err != nil { + return err + } + routed := routedOrgs(orgs) + + choices := routed + if tenant != "" { + choices = orgsOn(routed, tenant) + } + if picker.Name == "" && picker.Remembered == nil { + picker.Remembered = rememberedPick(router, tenant) + } + org, err := picker.Pick(choices) + if err != nil { + if tenant != "" { + return fmt.Errorf("signed in through %s, but none of your organizations on %s can be used: %w", router.Key, tenant, err) } - hostCreds = withInstallation(hostCreds, install) - fmt.Printf("Using installation %s (%s)\n", install.Name, install.DataHost) + return err } - err = SaveHostCredentials(host, hostCreds) + hostCreds := withSelection(HostCredentials{ + AccessToken: tokenResp.AccessToken, + RefreshToken: tokenResp.RefreshToken, + ExpiresAt: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Unix(), + }, org) + + tenants := map[string]bool{} + for _, o := range routed { + tenants[o.DataHost()] = true + } + + err = updateCredentials(func(creds Credentials) Credentials { + next := maps.Clone(creds) + for t := range tenants { + if t != router.Key { + delete(next, t) + } + } + next[router.Key] = hostCreds + return next + }) if err != nil { return fmt.Errorf("failed to save credentials: %w", err) } - err = SaveConfig(&Config{Host: host}) + err = updateHosts(func(hosts Hosts) Hosts { + next := maps.Clone(hosts) + for t := range tenants { + hc := next[t] + hc.AuthHost = router.AuthHost + next[t] = hc + } + hc := next[org.DataHost()] + hc.Org = pickOf(org) + next[org.DataHost()] = hc + return next + }) if err != nil { + return fmt.Errorf("failed to save host config: %w", err) + } + + if err := SaveConfig(&Config{Host: router.Key}); err != nil { return fmt.Errorf("failed to save config: %w", err) } + fmt.Printf("Using %s on %s\n", org, org.DataHost()) fmt.Println("\nAuthenticated successfully!") return nil } -func selectInstallation(ctx context.Context, token string, picker InstallationPicker) (Installation, error) { - installs, err := fetchInstallations(ctx, token) +// rememberedPick is the org last chosen on tenant, or on the router's +// selected data host when no tenant is named. +func rememberedPick(router Router, tenant string) *OrgPick { + if tenant == "" { + creds, err := LoadCredentials() + if err != nil { + return nil + } + tenant = creds[router.Key].DataHost + if tenant == "" { + return nil + } + } + hosts, err := LoadHosts() if err != nil { - return Installation{}, err + return nil } - return picker.Pick(installs) + return hosts[CredentialKey(tenant)].Org +} + +func pickOf(org Org) *OrgPick { + return &OrgPick{Name: org.Name, Key: org.Key, ID: org.ID} } -func withInstallation(hostCreds HostCredentials, install Installation) HostCredentials { - hostCreds.DataHost = install.DataHost - hostCreds.Installation = install.Name - hostCreds.QueryParameters = install.QueryParameters +// withSelection records org as the router login's selected installation, in +// the fields CLI v0.26.0 reads too. +func withSelection(hostCreds HostCredentials, org Org) HostCredentials { + hostCreds.DataHost = org.DataHost() + hostCreds.Installation = org.Name + hostCreds.QueryParameters = org.Query() return hostCreds } -// SwitchInstallation selects the named installation for the stored router credentials and makes the router the default host. -func SwitchInstallation(ctx context.Context, name string) (Installation, error) { - token, err := GetValidToken(ctx, RouterHost) +// RememberOrg makes org the default for its data host without moving the +// default host: a router login's selection follows only when it is already +// on that data host. +func RememberOrg(org Org) error { + dataHost := org.DataHost() + err := UpdateHostConfig(dataHost, func(hc HostConfig) HostConfig { + hc.Org = pickOf(org) + return hc + }) if err != nil { - return Installation{}, err + return fmt.Errorf("failed to save the organization: %w", err) + } + authHost, err := AuthHostFor(dataHost) + if err != nil { + return err } + router, onRouter := RouterFor(authHost) + if !onRouter { + return nil + } + return updateHostCredentials(router.Key, func(hostCreds HostCredentials) HostCredentials { + if hostCreds.DataHost != dataHost { + return hostCreds + } + return withSelection(hostCreds, org) + }) +} - install, err := selectInstallation(ctx, token, InstallationPicker{Name: name}) +// SelectOrg makes org the default for its data host and, for a router login, +// moves the router's selected data host to it and makes the router the +// default host. The login picker, 'nullify org use' and 'nullify auth switch +// --installation' save through here; the prompt uses RememberOrg, which does +// not move the default host. +func SelectOrg(org Org) error { + dataHost := org.DataHost() + if dataHost == "" { + return fmt.Errorf("%s is not served by a Nullify host yet", org.Name) + } + authHost, err := AuthHostFor(dataHost) if err != nil { - return Installation{}, err + return err } - err = updateHostCredentials(RouterHost, func(hostCreds HostCredentials) HostCredentials { - return withInstallation(hostCreds, install) + err = UpdateHostConfig(dataHost, func(hc HostConfig) HostConfig { + hc.Org = pickOf(org) + return hc }) if err != nil { - return Installation{}, fmt.Errorf("failed to save credentials: %w", err) + return fmt.Errorf("failed to save the organization: %w", err) } - if err := SaveConfig(&Config{Host: RouterHost}); err != nil { - return Installation{}, fmt.Errorf("failed to save config: %w", err) + router, onRouter := RouterFor(authHost) + if !onRouter { + return nil + } + err = updateHostCredentials(router.Key, func(hostCreds HostCredentials) HostCredentials { + return withSelection(hostCreds, org) + }) + if err != nil { + return fmt.Errorf("failed to save credentials: %w", err) + } + if err := SaveConfig(&Config{Host: router.Key}); err != nil { + return fmt.Errorf("failed to save config: %w", err) } - return install, nil + return nil +} + +// SwitchInstallation selects the named org among the router login's and makes the router the default host. +func SwitchInstallation(ctx context.Context, router Router, name string) (Org, error) { + token, err := GetValidToken(ctx, router.Key) + if err != nil { + return Org{}, err + } + + orgs, err := fetchOrgs(ctx, router.AuthHost, token) + if err != nil { + return Org{}, err + } + org, err := InstallationPicker{Name: name}.Pick(routedOrgs(orgs)) + if err != nil { + return Org{}, err + } + if err := BindTenant(org.DataHost(), router); err != nil { + return Org{}, err + } + return org, SelectOrg(org) +} + +// BindTenant records that tenant signs in through router, unless it already does. +func BindTenant(tenant string, router Router) error { + if authHost, err := AuthHostFor(tenant); err == nil && authHost == router.AuthHost { + return nil + } + return UpdateHostConfig(tenant, func(hc HostConfig) HostConfig { + hc.AuthHost = router.AuthHost + return hc + }) } // DataHost returns the host that serves data for the credentials stored under authHost. func DataHost(authHost string) (string, error) { - if !IsRouterHost(authHost) { + router, onRouter := RouterFor(authHost) + if !onRouter { return authHost, nil } @@ -191,7 +406,7 @@ func DataHost(authHost string) (string, error) { } return "", fmt.Errorf("load credentials: %w", err) } - dataHost := creds[RouterHost].DataHost + dataHost := creds[router.Key].DataHost if dataHost == "" { return "", ErrNoDataHost } @@ -207,37 +422,50 @@ func CheckSession(ctx context.Context, host string) error { return err } +// Logout removes the credentials that serve host. On a router tenant those +// are the router login, shared with every other tenant signed in through it. func Logout(host string) error { + host = normalizeHost(host) + authHost, err := AuthHostFor(host) + if err != nil { + return err + } + if err := DeleteHostCredentials(authHost); err != nil { + return err + } + // A login from before the auth host was recorded sits under host itself. return DeleteHostCredentials(host) } -func GetValidToken(ctx context.Context, host string) (string, error) { - host = normalizeHost(host) - - creds, err := LoadCredentials() +// storedCredentials returns the credentials that serve the API host and the +// auth host that issued them, which is where they must be refreshed. +func storedCredentials(host string) (HostCredentials, string, error) { + hostCreds, authHost, ok, err := LookupCredentials(normalizeHost(host)) + if errors.Is(err, os.ErrNotExist) { + return HostCredentials{}, "", fmt.Errorf("not authenticated - run 'nullify auth login'") + } if err != nil { - return "", fmt.Errorf("not authenticated - run 'nullify auth login'") + return HostCredentials{}, "", fmt.Errorf("read stored credentials: %w", err) } - - key := CredentialKey(host) - hostCreds, ok := creds[key] if !ok { - return "", fmt.Errorf("not authenticated for %s - run 'nullify auth login --host %s'", host, host) + return HostCredentials{}, "", fmt.Errorf("not authenticated for %s - run 'nullify auth login --host %s'", host, host) } + return hostCreds, authHost, nil +} - if hostCreds.ExpiresAt > 0 && time.Now().Unix() > hostCreds.ExpiresAt { - if hostCreds.RefreshToken != "" { - logger.L(ctx).Debug("access token expired, attempting refresh") - refreshed, err := refreshToken(ctx, host, hostCreds.RefreshToken) - if err != nil { - return "", fmt.Errorf("token expired and refresh failed - run 'nullify auth login': %w", err) - } - return refreshed, nil - } - return "", fmt.Errorf("token expired - run 'nullify auth login'") +func GetValidToken(ctx context.Context, host string) (string, error) { + hostCreds, _, err := storedCredentials(host) + if err != nil { + return "", err + } + if !expired(hostCreds) { + return hostCreds.AccessToken, nil } + return refreshOnce(ctx, host, false) +} - return hostCreds.AccessToken, nil +func expired(hostCreds HostCredentials) bool { + return hostCreds.ExpiresAt > 0 && time.Now().Unix() > hostCreds.ExpiresAt } // ErrNotRefreshable reports that the stored credentials for a host carry no @@ -250,23 +478,118 @@ var ErrNotRefreshable = errors.New("no refresh token stored") // the server rejects while the CLI still believes it is valid -- revocation, a // killed session, or a local clock running behind the server's. func ForceRefreshToken(ctx context.Context, host string) (string, error) { - host = normalizeHost(host) + return refreshOnce(ctx, host, true) +} - creds, err := LoadCredentials() +// refreshReuse is how long a refresh answers every caller that was waiting on +// it, so concurrent calls rejected with the same token refresh once. +var refreshReuse = 30 * time.Second + +type refreshState struct { + mu sync.Mutex + at time.Time +} + +var refreshes sync.Map + +// refreshOnce refreshes host's token at its auth host, one refresh at a time +// per auth host in this process. +func refreshOnce(ctx context.Context, host string, force bool) (string, error) { + _, authHost, err := storedCredentials(host) if err != nil { - return "", fmt.Errorf("not authenticated - run 'nullify auth login'") + return "", err + } + dir, err := configDir() + if err != nil { + return "", err } + v, _ := refreshes.LoadOrStore(dir+"|"+CredentialKey(authHost), &refreshState{}) + state := v.(*refreshState) + state.mu.Lock() + defer state.mu.Unlock() - hostCreds, ok := creds[CredentialKey(host)] - if !ok { - return "", fmt.Errorf("not authenticated for %s - run 'nullify auth login --host %s'", host, host) + hostCreds, authHost, err := storedCredentials(host) + if err != nil { + return "", err + } + if !expired(hostCreds) && (!force || time.Since(state.at) < refreshReuse) { + return hostCreds.AccessToken, nil } if hostCreds.RefreshToken == "" { - return "", ErrNotRefreshable + if force { + return "", ErrNotRefreshable + } + return "", fmt.Errorf("token expired - run 'nullify auth login'") + } + + logger.L(ctx).Debug("refreshing access token", logger.String("authHost", authHost)) + token, err := refreshToken(ctx, authHost, hostCreds) + if err != nil { + if force { + return "", err + } + return "", fmt.Errorf("token expired and refresh failed - run 'nullify auth login': %w", err) + } + state.at = time.Now() + return token, nil +} + +// routerAuthHost reports whether host is a router API host a tenant may hand +// its CLI login to. The CLI sends the user's login and refresh token wherever +// the tenant says, so any other answer is refused rather than followed. +func routerAuthHost(host string) bool { + for _, r := range routers { + if host == r.AuthHost { + return true + } + } + return false +} + +// DiscoverAuthHost asks host where its CLI login runs (GET /auth/cli/config). +// A stack that predates the endpoint answers 404 and logs in on its own host. +// Any other failure, or an answer that is neither this tenant nor a known +// router, is an error: credentials never go to a host the CLI did not expect, +// and a failed discovery is never retried on another host. +func DiscoverAuthHost(ctx context.Context, host string) (string, error) { + own := apiHost(host) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, authBaseURL(host)+"/auth/cli/config", nil) + if err != nil { + return "", err } - logger.L(ctx).Debug("forcing access token refresh") - return refreshToken(ctx, host, hostCreds.RefreshToken) + resp, err := authClient().Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK: + case http.StatusNotFound: + return own, nil + default: + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return "", fmt.Errorf("auth config request returned status %d: %s", resp.StatusCode, string(body)) + } + + var config struct { + AuthHost string `json:"auth_host"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 4<<10)).Decode(&config); err != nil { + return "", fmt.Errorf("decode auth config: %w", err) + } + + authHost := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(config.AuthHost)), ":443") + switch { + case strings.EqualFold(authHost, own): + return own, nil + case routerAuthHost(authHost): + return authHost, nil + default: + return "", fmt.Errorf("%s names %q as its login host, which is neither this tenant nor a Nullify router", own, config.AuthHost) + } } func createCLISession(ctx context.Context, host string, port int) (*cliSessionResponse, error) { @@ -283,7 +606,7 @@ func createCLISession(ctx context.Context, host string, port int) (*cliSessionRe } req.Header.Set("Content-Type", "application/json") - resp, err := httpClient.Do(req) + resp, err := authClient().Do(req) if err != nil { return nil, err } @@ -317,7 +640,7 @@ func fetchCLIToken(ctx context.Context, host string, sessionID string, redemptio } req.Header.Set("Content-Type", "application/json") - resp, err := httpClient.Do(req) + resp, err := authClient().Do(req) if err != nil { return nil, err } @@ -337,16 +660,24 @@ func fetchCLIToken(ctx context.Context, host string, sessionID string, redemptio return &tokenResp, nil } -func refreshToken(ctx context.Context, host string, refreshTok string) (string, error) { - refreshURL := authBaseURL(host) + "/auth/refresh_token" +// errLoginReplaced reports that the login a refresh started from was removed +// or replaced while it ran, so its result must not be saved over the new one. +var errLoginReplaced = errors.New("the stored login changed during the refresh - run the command again") + +// refreshToken exchanges stored's refresh token at authHost and saves the new +// access token over it. The org scope the server returns is not saved: it is +// the server's default (the lowest org id), not a choice the user made. The +// result is saved only if the stored login is still the one it started from. +func refreshToken(ctx context.Context, authHost string, stored HostCredentials) (string, error) { + refreshURL := authBaseURL(authHost) + "/auth/refresh_token" req, err := http.NewRequestWithContext(ctx, http.MethodGet, refreshURL, nil) if err != nil { return "", err } - req.AddCookie(&http.Cookie{Name: "refresh_token", Value: refreshTok}) + req.AddCookie(&http.Cookie{Name: "refresh_token", Value: stored.RefreshToken}) - resp, err := httpClient.Do(req) + resp, err := authClient().Do(req) if err != nil { return "", err } @@ -357,9 +688,8 @@ func refreshToken(ctx context.Context, host string, refreshTok string) (string, } var result struct { - AccessToken string `json:"accessToken"` - ExpiresIn int `json:"expiresIn"` - QueryParameters map[string]string `json:"queryParameters"` + AccessToken string `json:"accessToken"` + ExpiresIn int `json:"expiresIn"` } err = json.NewDecoder(resp.Body).Decode(&result) @@ -386,19 +716,25 @@ func refreshToken(ctx context.Context, host string, refreshTok string) (string, } expiresAt := time.Now().Add(time.Duration(result.ExpiresIn) * time.Second).Unix() - - err = updateHostCredentials(host, func(hostCreds HostCredentials) HostCredentials { - hostCreds.AccessToken = result.AccessToken - hostCreds.RefreshToken = refreshTok - hostCreds.ExpiresAt = expiresAt - if hostCreds.DataHost == "" { - hostCreds.QueryParameters = result.QueryParameters + replaced := false + err = updateCredentials(func(creds Credentials) Credentials { + current, ok := creds[CredentialKey(authHost)] + if !ok || current.RefreshToken != stored.RefreshToken { + replaced = true + return creds } - return hostCreds + next := maps.Clone(creds) + current.AccessToken = result.AccessToken + current.ExpiresAt = expiresAt + next[CredentialKey(authHost)] = current + return next }) if err != nil { logger.L(ctx).Warn("failed to save refreshed credentials", logger.Err(err)) } + if replaced { + return "", errLoginReplaced + } return result.AccessToken, nil } @@ -429,6 +765,9 @@ func apiHost(host string) string { return "api." + host } +// openBrowser is OpenBrowser, a var so tests can stand in for the user. +var openBrowser = OpenBrowser + // OpenBrowser opens the given URL in the user's default browser. func OpenBrowser(url string) error { var cmd *exec.Cmd diff --git a/internal/auth/login_router_test.go b/internal/auth/login_router_test.go index 65af2c2..4a38031 100644 --- a/internal/auth/login_router_test.go +++ b/internal/auth/login_router_test.go @@ -39,6 +39,9 @@ func (f *fakeLoginServer) handle(t *testing.T) http.HandlerFunc { ExpiresIn: 3600, QueryParameters: map[string]string{"fromToken": "1"}, }) + case "/auth/cli/config": + // A stack from before discovery: it signs in itself. + w.WriteHeader(http.StatusNotFound) case "/admin/installations": f.installationsCalls++ assert.Equal(t, "Bearer access", r.Header.Get("Authorization")) @@ -70,24 +73,8 @@ func requireOnlyRouterHost(t *testing.T, hosts []string, wantCalls int) { t.Helper() require.Len(t, hosts, wantCalls) for _, host := range hosts { - require.Equal(t, RouterHost, host) - } -} - -// stubBrowser completes the browser step by calling the local callback URL. -func stubBrowser(t *testing.T) { - t.Helper() - old := openBrowser - openBrowser = func(url string) error { - go func() { - resp, err := http.Get(url) - if err == nil { - resp.Body.Close() - } - }() - return nil + require.Equal(t, RouterHost, CredentialKey(host)) } - t.Cleanup(func() { openBrowser = old }) } func installationJSON(name, baseURL string) string { @@ -142,14 +129,14 @@ func TestLoginRouterHost(t *testing.T) { name: "several installations without a terminal", host: "nullify.ai", installations: []string{acme, beta}, - wantErr: "pass --installation ", + wantErr: "pass --org ", }, { name: "unknown installation name", host: "app.nullify.ai", picker: InstallationPicker{Name: "gamma"}, installations: []string{acme, beta}, - wantErr: `installation "gamma" not found`, + wantErr: `organization "gamma" not found`, }, } for _, tt := range tests { @@ -235,7 +222,7 @@ func TestRefreshTokenKeepsRouterSelection(t *testing.T) { require.Equal(t, map[string]string{"githubOwnerId": "7"}, got.QueryParameters) } -func TestRefreshTokenTakesResponseQueryParametersWithoutDataHost(t *testing.T) { +func TestRefreshTokenKeepsTheLoginsQueryParameters(t *testing.T) { newAuthServer(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"accessToken":"new","expiresIn":60}`)) }) @@ -250,7 +237,7 @@ func TestRefreshTokenTakesResponseQueryParametersWithoutDataHost(t *testing.T) { creds, err := LoadCredentials() require.NoError(t, err) - require.Empty(t, creds["acme.nullify.ai"].QueryParameters) + require.Equal(t, map[string]string{"githubOwnerId": "7"}, creds["acme.nullify.ai"].QueryParameters, "a refresh never changes the org") } func TestRefreshTokenRereadsCredentialsBeforeWriting(t *testing.T) { @@ -324,16 +311,17 @@ func TestSwitchInstallation(t *testing.T) { })) require.NoError(t, SaveConfig(&Config{Host: "other.nullify.ai"})) - _, err := SwitchInstallation(context.Background(), "gamma") - require.ErrorContains(t, err, `installation "gamma" not found`) + prod, _ := RouterFor(RouterHost) + _, err := SwitchInstallation(context.Background(), prod, "gamma") + require.ErrorContains(t, err, `organization "gamma" not found`) creds, err := LoadCredentials() require.NoError(t, err) require.Equal(t, "acme", creds[RouterHost].Installation) - install, err := SwitchInstallation(context.Background(), "beta") + install, err := SwitchInstallation(context.Background(), prod, "beta") require.NoError(t, err) requireOnlyRouterHost(t, *authHosts, 2) - require.Equal(t, "beta.nullify.ai", install.DataHost) + require.Equal(t, "beta.nullify.ai", install.DataHost()) creds, err = LoadCredentials() require.NoError(t, err) @@ -350,6 +338,7 @@ func TestSwitchInstallation(t *testing.T) { func TestSwitchInstallationRequiresRouterLogin(t *testing.T) { t.Setenv("HOME", t.TempDir()) - _, err := SwitchInstallation(context.Background(), "acme") + prod, _ := RouterFor(RouterHost) + _, err := SwitchInstallation(context.Background(), prod, "acme") require.ErrorContains(t, err, "not authenticated") } diff --git a/internal/auth/login_test.go b/internal/auth/login_test.go index 9c5b3e2..c4caec3 100644 --- a/internal/auth/login_test.go +++ b/internal/auth/login_test.go @@ -46,7 +46,7 @@ func refreshHandler(w http.ResponseWriter, _ *http.Request) { func TestRefreshTokenReadsSetCookie(t *testing.T) { newAuthServer(t, refreshHandler) - got, err := refreshToken(context.Background(), "example.nullify.ai", "stored-refresh") + got, err := refreshStored(t, "example.nullify.ai", "stored-refresh") if err != nil { t.Fatalf("refreshToken: %v", err) } @@ -70,8 +70,32 @@ func TestRefreshTokenReadsSetCookie(t *testing.T) { if d := saved.ExpiresAt - time.Now().Unix(); d < 3500 || d > 3600 { t.Errorf("ExpiresAt delta = %ds, want ~3600", d) } - if saved.QueryParameters["githubOwnerId"] != "42" { - t.Errorf("saved QueryParameters = %v, want githubOwnerId=42", saved.QueryParameters) +} + +// The refresh response carries the server's default org (the lowest id). It +// is not the user's pick, so it must not land in the store or replace the +// scope already there. +func TestRefreshTokenLeavesStoredOrgAlone(t *testing.T) { + newAuthServer(t, refreshHandler) + stored := HostCredentials{ + AccessToken: "old", + RefreshToken: "rt", + QueryParameters: map[string]string{"githubOwnerId": "7"}, + } + if err := SaveHostCredentials("example.nullify.ai", stored); err != nil { + t.Fatalf("SaveHostCredentials: %v", err) + } + + if _, err := refreshToken(context.Background(), "example.nullify.ai", stored); err != nil { + t.Fatalf("refreshToken: %v", err) + } + + creds, err := LoadCredentials() + if err != nil { + t.Fatalf("LoadCredentials: %v", err) + } + if got := creds[CredentialKey("example.nullify.ai")].QueryParameters; got["githubOwnerId"] != "7" { + t.Errorf("saved QueryParameters = %v, want the stored githubOwnerId=7", got) } } @@ -81,7 +105,7 @@ func TestRefreshTokenBodyTokenWins(t *testing.T) { _, _ = w.Write([]byte(`{"accessToken":"body-token","expiresIn":60}`)) }) - got, err := refreshToken(context.Background(), "example.nullify.ai", "rt") + got, err := refreshStored(t, "example.nullify.ai", "rt") if err != nil { t.Fatalf("refreshToken: %v", err) } @@ -98,7 +122,7 @@ func TestRefreshTokenIgnoresEmptyCookie(t *testing.T) { _, _ = w.Write([]byte(`{"expiresIn":3600}`)) }) - got, err := refreshToken(context.Background(), "example.nullify.ai", "rt") + got, err := refreshStored(t, "example.nullify.ai", "rt") if err != nil { t.Fatalf("refreshToken: %v", err) } @@ -119,7 +143,7 @@ func TestRefreshTokenExpiresFallback(t *testing.T) { _, _ = w.Write([]byte(`{}`)) }) - if _, err := refreshToken(context.Background(), "example.nullify.ai", "rt"); err != nil { + if _, err := refreshStored(t, "example.nullify.ai", "rt"); err != nil { t.Fatalf("refreshToken: %v", err) } @@ -137,7 +161,7 @@ func TestRefreshTokenNoTokenAnywhere(t *testing.T) { _, _ = w.Write([]byte(`{"expiresIn":3600}`)) }) - _, err := refreshToken(context.Background(), "example.nullify.ai", "rt") + _, err := refreshStored(t, "example.nullify.ai", "rt") if err == nil { t.Fatal("want an error when neither body nor cookie carries a token") } @@ -148,7 +172,7 @@ func TestRefreshTokenNon200(t *testing.T) { w.WriteHeader(http.StatusUnauthorized) }) - _, err := refreshToken(context.Background(), "example.nullify.ai", "rt") + _, err := refreshStored(t, "example.nullify.ai", "rt") if err == nil { t.Fatal("want an error on a non-200 refresh") } @@ -220,3 +244,14 @@ func seed(t *testing.T, host, access, refresh string, expiresAt int64) { t.Fatalf("seed credentials: %v", err) } } + +// refreshStored stores a login under host with refresh token rt and refreshes +// it, as a command would. +func refreshStored(t *testing.T, host, rt string) (string, error) { + t.Helper() + stored := HostCredentials{RefreshToken: rt} + if err := SaveHostCredentials(host, stored); err != nil { + t.Fatalf("SaveHostCredentials: %v", err) + } + return refreshToken(context.Background(), host, stored) +} diff --git a/internal/auth/orgs.go b/internal/auth/orgs.go new file mode 100644 index 0000000..559bfe3 --- /dev/null +++ b/internal/auth/orgs.go @@ -0,0 +1,429 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" +) + +// Org is one organization a request can be scoped to on an API host. Key and +// ID are the query parameter that scopes a request to it, e.g. +// githubOwnerId=1001001. +type Org struct { + Name string `json:"name"` + Provider string `json:"provider"` + Key string `json:"key"` + ID string `json:"id"` + // Host is the API host serving the org, empty when the router has not + // routed it yet. + Host string `json:"host,omitempty"` + // Status is the router's provisioning status: ready, pending or + // unavailable. Empty outside router mode. + Status string `json:"status,omitempty"` +} + +// DataHost is the org's API host in credential key form, "" when unrouted. +func (o Org) DataHost() string { + return CredentialKey(o.Host) +} + +// Query returns the query parameters that scope a request to the org. +func (o Org) Query() map[string]string { + return map[string]string{o.Key: o.ID} +} + +func (o Org) String() string { + return fmt.Sprintf("%s (%s %s)", o.Name, o.Key, o.ID) +} + +// Matches reports whether ref names the org, by name (case-insensitive) or id. +func (o Org) Matches(ref string) bool { + ref = strings.TrimSpace(ref) + return ref != "" && (strings.EqualFold(o.Name, ref) || o.ID == ref) +} + +// OrgList is what the signed-in user can see from one API host. +type OrgList struct { + // Orgs are served by this host and can scope its requests. + Orgs []Org + // Pending are the user's orgs the router has not finished routing. + Pending []Org + // Elsewhere are the user's orgs served by another Nullify host. + Elsewhere []Org +} + +// providerKeys maps a provider family to the query parameter that scopes a +// request to one of its orgs. +var providerKeys = map[string]string{ + "github": "githubOwnerId", + "azure": "azureOrganizationId", + "bitbucket": "bitbucketWorkspaceId", + "gitlab": "gitlabGroupId", +} + +type installationsResponse struct { + Installations []struct { + Name string `json:"name"` + Provider struct { + ID string `json:"id"` + GitHub json.RawMessage `json:"github"` + Azure json.RawMessage `json:"azure"` + Bitbucket json.RawMessage `json:"bitbucket"` + GitLab json.RawMessage `json:"gitlab"` + } `json:"provider"` + NullifyBaseURL *string `json:"nullifyBaseUrl"` + QueryParameters map[string]string `json:"queryParameters"` + ProvisioningStatus string `json:"provisioningStatus"` + } `json:"installations"` +} + +// ListOrgs returns the orgs token can scope requests to on host, read from +// GET /admin/installations on listHost: the router for a router login, the +// tenant itself for a legacy login or an API token. An org counts for host +// when its nullifyBaseUrl names host; when listHost is host itself, an +// installation with no base URL is the tenant's own. +func ListOrgs(ctx context.Context, host, listHost, token string) (OrgList, error) { + orgs, err := fetchOrgs(ctx, listHost, token) + if err != nil { + return OrgList{}, err + } + return sortOrgs(host, listHost, orgs), nil +} + +// fetchOrgs reads every installation listHost reports for token. On a router +// an org's Host is set only when its base URL is one of that router's tenant +// origins; any other base URL marks the org unavailable and is never used. +func fetchOrgs(ctx context.Context, listHost, token string) ([]Org, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, authBaseURL(listHost)+"/admin/installations", nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := authClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + err := fmt.Errorf("list organizations on %s: status %d: %s", apiHost(listHost), resp.StatusCode, string(body)) + if resp.StatusCode == http.StatusUnauthorized { + err = fmt.Errorf("%w: %w", ErrTokenRejected, err) + } + return nil, err + } + + var out installationsResponse + if err := json.NewDecoder(io.LimitReader(resp.Body, 10<<20)).Decode(&out); err != nil { + return nil, fmt.Errorf("decode organizations: %w", err) + } + + router, onRouter := RouterFor(listHost) + orgs := make([]Org, 0, len(out.Installations)) + for _, inst := range out.Installations { + family := providerFamily(inst.Provider.ID, inst.Provider.GitHub, inst.Provider.Azure, inst.Provider.Bitbucket, inst.Provider.GitLab) + key := orgKey(family, inst.QueryParameters) + id := inst.QueryParameters[key] + if key == "" || id == "" { + continue + } + org := Org{Name: inst.Name, Provider: family, Key: key, ID: id, Status: inst.ProvisioningStatus} + + if inst.NullifyBaseURL != nil { + if !onRouter { + org.Host = baseURLHost(*inst.NullifyBaseURL) + } else if dataHost, err := router.dataHost(strings.TrimSpace(*inst.NullifyBaseURL)); err == nil { + org.Host = apiHost(dataHost) + } + if org.Host == "" { + org.Status = "unavailable" + } + } + orgs = append(orgs, org) + } + return orgs, nil +} + +// orgKey is the query parameter that scopes a request to an org of family, +// or, when the router did not say which provider it is, the one known +// parameter its queryParameters carry. +func orgKey(family string, query map[string]string) string { + if key := providerKeys[family]; key != "" { + return key + } + found := "" + for _, key := range providerKeys { + if query[key] == "" { + continue + } + if found != "" { + return "" + } + found = key + } + return found +} + +func sortOrgs(host, listHost string, orgs []Org) OrgList { + target := strings.ToLower(apiHost(host)) + listIsTarget := strings.EqualFold(apiHost(listHost), target) + + var list OrgList + for _, org := range orgs { + switch { + case org.Host == target && (org.Status == "pending" || org.Status == "unavailable"): + list.Pending = append(list.Pending, org) + case org.Host == target: + list.Orgs = append(list.Orgs, org) + case org.Host != "": + list.Elsewhere = append(list.Elsewhere, org) + case org.Status == "pending" || org.Status == "unavailable": + list.Pending = append(list.Pending, org) + case listIsTarget: + org.Host = target + list.Orgs = append(list.Orgs, org) + default: + list.Pending = append(list.Pending, org) + } + } + + for _, orgs := range [][]Org{list.Orgs, list.Pending, list.Elsewhere} { + sort.Slice(orgs, func(i, j int) bool { return strings.ToLower(orgs[i].Name) < strings.ToLower(orgs[j].Name) }) + } + return list +} + +func providerFamily(id string, github, azure, bitbucket, gitlab json.RawMessage) string { + present := func(raw json.RawMessage) bool { return len(raw) > 0 && string(raw) != "null" } + switch { + case present(github): + return "github" + case present(azure): + return "azure" + case present(bitbucket): + return "bitbucket" + case present(gitlab): + return "gitlab" + } + return strings.ToLower(id) +} + +// baseURLHost returns the API host a tenant's nullifyBaseUrl names, lower +// case and without a default port, or "" when it is not a plain https origin. +// It accepts what the router's own route validation accepts. +func baseURLHost(raw string) string { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u.Scheme != "https" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return "" + } + if u.Path != "" && u.Path != "/" { + return "" + } + if port := u.Port(); port != "" && port != "443" { + return "" + } + return strings.ToLower(u.Hostname()) +} + +// OrgPick is an org the user chose explicitly for a host, with `nullify org +// use` or at the prompt. It is the only scope the CLI remembers. +type OrgPick struct { + Name string `json:"name"` + Key string `json:"key"` + ID string `json:"id"` +} + +// OrgInputs are the ways a command can name its org, in precedence order. +type OrgInputs struct { + // Flag is --org, or a caller's equivalent (an MCP tool's org argument). + Flag string + // FlagSource names Flag in errors, e.g. "--org". + FlagSource string + // EnvOrg is NULLIFY_ORG. + EnvOrg string + // EnvGitHubOwnerID is NULLIFY_GITHUB_OWNER_ID. + EnvGitHubOwnerID string + // Stored is the remembered pick for this host. + Stored *OrgPick + // GitOwner is the owner in the current directory's git remote. + GitOwner string + // AnyHost lets an explicit name or id match an org on another host of + // the same router, for a command that moves to that org's host. + AnyHost bool +} + +// ErrOrgNotChosen reports that several orgs could serve the request and +// nothing chose one. Orgs lists the choices. +type ErrOrgNotChosen struct { + Host string + Orgs []Org + // Unavailable is the remembered org when it is no longer one of Orgs. + Unavailable *OrgPick +} + +func (e *ErrOrgNotChosen) Error() string { + names := make([]string, len(e.Orgs)) + for i, o := range e.Orgs { + names[i] = o.String() + } + if e.Unavailable != nil { + return fmt.Sprintf("your default organization %s (%s %s) is not available on %s; choose one with --org or 'nullify org use': %s", + e.Unavailable.Name, e.Unavailable.Key, e.Unavailable.ID, e.Host, strings.Join(names, ", ")) + } + return fmt.Sprintf("%s serves %d organizations; choose one with --org , NULLIFY_ORG, or 'nullify org use': %s", + e.Host, len(e.Orgs), strings.Join(names, ", ")) +} + +// ErrTokenRejected reports that the org list refused the token, which can +// happen before its stored expiry (revocation, clock skew). +var ErrTokenRejected = errors.New("token rejected") + +// ErrOrgNotFound reports that an org named by the user is not one of theirs +// on this host. +var ErrOrgNotFound = errors.New("organization not available") + +// ErrNoOrgs reports that the user has no org routed to this host. +var ErrNoOrgs = errors.New("no organization on this host is available to you") + +// ResolveOrg picks the org a request on host runs as; see ResolveOrgReason. +func ResolveOrg(host string, list OrgList, in OrgInputs) (Org, error) { + org, _, err := ResolveOrgReason(host, list, in) + return org, err +} + +// ResolveOrgReason picks the org a request on host runs as, from list and in, +// and says which rule picked it: an explicit name or id (--org, then +// NULLIFY_ORG, then NULLIFY_GITHUB_OWNER_ID), the git remote owner if it +// names exactly one org, the remembered pick, the only org. An explicit name +// that matches nothing is an error, never a fallthrough, and so is a +// remembered pick that is no longer available: another org is never put in +// its place. Anything left ambiguous returns *ErrOrgNotChosen; the lowest id +// is never a default. +func ResolveOrgReason(host string, list OrgList, in OrgInputs) (Org, string, error) { + explicit := []struct{ ref, source string }{ + {in.Flag, in.FlagSource}, + {in.EnvOrg, "NULLIFY_ORG"}, + } + for _, e := range explicit { + if strings.TrimSpace(e.ref) == "" { + continue + } + org, err := findOrg(host, list, e.ref, e.source, in.AnyHost) + return org, e.source, err + } + if id := strings.TrimSpace(in.EnvGitHubOwnerID); id != "" { + for _, o := range list.Orgs { + if o.Key == "githubOwnerId" && o.ID == id { + return o, "NULLIFY_GITHUB_OWNER_ID", nil + } + } + return Org{}, "", notFound(host, list, id, "NULLIFY_GITHUB_OWNER_ID") + } + + if owner := strings.TrimSpace(in.GitOwner); owner != "" { + var match []Org + for _, o := range list.Orgs { + if strings.EqualFold(o.Name, owner) { + match = append(match, o) + } + } + if len(match) == 1 { + return match[0], "git remote owner", nil + } + } + + if in.Stored != nil { + for _, o := range list.Orgs { + if o.Key == in.Stored.Key && o.ID == in.Stored.ID { + return o, "your default", nil + } + } + if len(list.Orgs) == 0 { + return Org{}, "", noOrgs(host, list) + } + return Org{}, "", &ErrOrgNotChosen{Host: host, Orgs: list.Orgs, Unavailable: in.Stored} + } + + switch len(list.Orgs) { + case 0: + return Org{}, "", noOrgs(host, list) + case 1: + return list.Orgs[0], "your only organization", nil + default: + return Org{}, "", &ErrOrgNotChosen{Host: host, Orgs: list.Orgs} + } +} + +func findOrg(host string, list OrgList, ref, source string, anyHost bool) (Org, error) { + var match []Org + for _, o := range list.Orgs { + if o.Matches(ref) { + match = append(match, o) + } + } + if len(match) == 0 && anyHost { + for _, o := range list.Elsewhere { + if o.Matches(ref) && o.Status != "pending" && o.Status != "unavailable" { + match = append(match, o) + } + } + } + switch len(match) { + case 1: + return match[0], nil + case 0: + return Org{}, notFound(host, list, ref, source) + default: + return Org{}, &ErrOrgNotChosen{Host: host, Orgs: match} + } +} + +func notFound(host string, list OrgList, ref, source string) error { + for _, o := range list.Pending { + if o.Matches(ref) { + return fmt.Errorf("%w: %s %q: %s is not ready for use yet (status %s); try again later", ErrOrgNotFound, source, ref, o.Name, statusOr(o.Status, "pending")) + } + } + for _, o := range list.Elsewhere { + if o.Matches(ref) && (o.Status == "pending" || o.Status == "unavailable") { + return fmt.Errorf("%w: %s %q: %s on %s is not ready for use yet (status %s); try again later", ErrOrgNotFound, source, ref, o.Name, o.Host, o.Status) + } + if o.Matches(ref) { + return fmt.Errorf("%w: %s %q: %s is served by %s, not %s; use --host %s", ErrOrgNotFound, source, ref, o.Name, o.Host, apiHost(host), strings.TrimPrefix(o.Host, "api.")) + } + } + return fmt.Errorf("%w: %s %q is not one of your organizations on %s (%s)", ErrOrgNotFound, source, ref, apiHost(host), describeOrgs(list.Orgs)) +} + +func noOrgs(host string, list OrgList) error { + if len(list.Pending) > 0 { + return fmt.Errorf("%w: %s; %d of your organizations are not ready yet", ErrNoOrgs, apiHost(host), len(list.Pending)) + } + return fmt.Errorf("%w: %s", ErrNoOrgs, apiHost(host)) +} + +func describeOrgs(orgs []Org) string { + if len(orgs) == 0 { + return "none available" + } + names := make([]string, len(orgs)) + for i, o := range orgs { + names[i] = o.Name + } + return "available: " + strings.Join(names, ", ") +} + +func statusOr(s, fallback string) string { + if s == "" { + return fallback + } + return s +} diff --git a/internal/auth/orgs_test.go b/internal/auth/orgs_test.go new file mode 100644 index 0000000..2da8ec5 --- /dev/null +++ b/internal/auth/orgs_test.go @@ -0,0 +1,224 @@ +package auth + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" +) + +var ( + acmeWeb = Org{Name: "acme-web", Provider: "github", Key: "githubOwnerId", ID: "1001001", Host: tenantAPI} + acmePay = Org{Name: "acme-payments", Provider: "github", Key: "githubOwnerId", ID: "2002002", Host: tenantAPI} + pendingOrg = Org{Name: "newco", Provider: "github", Key: "githubOwnerId", ID: "555", Status: "pending"} + otherStack = Org{Name: "elsewhere-inc", Provider: "github", Key: "githubOwnerId", ID: "666", Host: "api.other.nullify.ai"} +) + +func twoOrgs() OrgList { + return OrgList{Orgs: []Org{acmeWeb, acmePay}, Pending: []Org{pendingOrg}, Elsewhere: []Org{otherStack}} +} + +func TestResolveOrgPrecedence(t *testing.T) { + stored := &OrgPick{Name: "acme-payments", Key: "githubOwnerId", ID: "2002002"} + stale := &OrgPick{Name: "gone", Key: "githubOwnerId", ID: "1"} + + tests := []struct { + name string + list OrgList + in OrgInputs + want Org + }{ + {"--org by name", twoOrgs(), OrgInputs{Flag: "ACME-WEB"}, acmeWeb}, + {"--org by id", twoOrgs(), OrgInputs{Flag: "2002002"}, acmePay}, + {"--org beats NULLIFY_ORG", twoOrgs(), OrgInputs{Flag: "acme-payments", EnvOrg: "acme-web"}, acmePay}, + {"NULLIFY_ORG beats NULLIFY_GITHUB_OWNER_ID", twoOrgs(), OrgInputs{EnvOrg: "acme-payments", EnvGitHubOwnerID: "1001001"}, acmePay}, + {"NULLIFY_GITHUB_OWNER_ID beats the stored pick", twoOrgs(), OrgInputs{EnvGitHubOwnerID: "1001001", Stored: stored}, acmeWeb}, + {"NULLIFY_ORG beats the stored pick", twoOrgs(), OrgInputs{EnvOrg: "acme-web", Stored: stored}, acmeWeb}, + {"git owner beats the stored pick", twoOrgs(), OrgInputs{Stored: stored, GitOwner: "acme-web"}, acmeWeb}, + {"stored pick no longer in the list falls through to the git owner", twoOrgs(), OrgInputs{Stored: stale, GitOwner: "ACME-WEB"}, acmeWeb}, + {"git owner names one org", twoOrgs(), OrgInputs{GitOwner: "acme-payments"}, acmePay}, + {"git owner matching nothing falls through to the sole org", OrgList{Orgs: []Org{acmeWeb}}, OrgInputs{GitOwner: "someone-else"}, acmeWeb}, + {"sole org", OrgList{Orgs: []Org{acmePay}, Pending: []Org{pendingOrg}}, OrgInputs{}, acmePay}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.in.FlagSource = "--org" + got, err := ResolveOrg(tenantHost, tt.list, tt.in) + if err != nil { + t.Fatalf("ResolveOrg: %v", err) + } + if got != tt.want { + t.Errorf("org = %v, want %v", got, tt.want) + } + }) + } +} + +func TestResolveOrgNeverDefaultsToTheLowestID(t *testing.T) { + stale := &OrgPick{Name: "gone", Key: "githubOwnerId", ID: "1"} + _, err := ResolveOrg(tenantHost, twoOrgs(), OrgInputs{Stored: stale, GitOwner: "someone-else"}) + + var notChosen *ErrOrgNotChosen + if !errors.As(err, ¬Chosen) { + t.Fatalf("err = %v, want *ErrOrgNotChosen", err) + } + if len(notChosen.Orgs) != 2 { + t.Errorf("choices = %v, want both orgs", notChosen.Orgs) + } + for _, want := range []string{"acme-web", "acme-payments", "--org", "nullify org use"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not mention %q", err, want) + } + } +} + +func TestResolveOrgExplicitMissIsAnError(t *testing.T) { + tests := []struct { + name string + in OrgInputs + want string + }{ + {"unknown name does not fall through to the stored pick", OrgInputs{Flag: "nope", FlagSource: "--org", Stored: &OrgPick{Key: "githubOwnerId", ID: "2002002"}}, `--org "nope" is not one of your organizations`}, + {"unknown NULLIFY_ORG", OrgInputs{EnvOrg: "nope"}, `NULLIFY_ORG "nope"`}, + {"unknown NULLIFY_GITHUB_OWNER_ID", OrgInputs{EnvGitHubOwnerID: "999"}, `NULLIFY_GITHUB_OWNER_ID "999"`}, + {"pending route reported apart", OrgInputs{Flag: "newco", FlagSource: "--org"}, "not ready for use yet (status pending)"}, + {"org on another host reported apart", OrgInputs{Flag: "elsewhere-inc", FlagSource: "--org"}, "served by api.other.nullify.ai"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ResolveOrg(tenantHost, twoOrgs(), tt.in) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("err = %v, want one containing %q", err, tt.want) + } + }) + } +} + +func TestResolveOrgWithNoOrgs(t *testing.T) { + _, err := ResolveOrg(tenantHost, OrgList{Pending: []Org{pendingOrg}}, OrgInputs{}) + if !errors.Is(err, ErrNoOrgs) || !strings.Contains(err.Error(), "not ready yet") { + t.Fatalf("err = %v, want ErrNoOrgs mentioning the pending org", err) + } +} + +const routerInstallations = `{"installations": [ + {"name": "acme-payments", "avatarUrl": "", "provider": {"id": "GitHub", "github": {"ownerId": 2002002}}, + "nullifyBaseUrl": "https://api.acme.nullify.ai", "queryParameters": {"githubOwnerId": "2002002"}, "provisioningStatus": "ready"}, + {"name": "acme-web", "avatarUrl": "", "provider": {"id": "GitHub", "github": {"ownerId": 1001001}}, + "nullifyBaseUrl": "https://API.acme.nullify.ai:443/", "queryParameters": {"githubOwnerId": "1001001"}, "provisioningStatus": "ready"}, + {"name": "elsewhere-inc", "avatarUrl": "", "provider": {"id": "GitHub", "github": {"ownerId": 666}}, + "nullifyBaseUrl": "https://api.other.nullify.ai", "queryParameters": {"githubOwnerId": "666"}, "provisioningStatus": "ready"}, + {"name": "newco", "avatarUrl": "", "provider": {"id": "GitHub", "github": {"ownerId": 555}}, + "queryParameters": {"githubOwnerId": "555"}, "provisioningStatus": "pending"}, + {"name": "pathy", "avatarUrl": "", "provider": {"id": "GitHub", "github": {"ownerId": 777}}, + "nullifyBaseUrl": "https://api.acme.nullify.ai/evil", "queryParameters": {"githubOwnerId": "777"}, "provisioningStatus": "ready"}, + {"name": "contoso", "avatarUrl": "", "provider": {"id": "Azure", "azure": {"organizationId": "c-1"}}, + "nullifyBaseUrl": "https://api.acme.nullify.ai", "queryParameters": {"azureOrganizationId": "c-1"}, "provisioningStatus": "ready"}, + {"name": "ws", "avatarUrl": "", "provider": {"id": "Bitbucket", "bitbucket": {"workspaceId": "w-1"}}, + "nullifyBaseUrl": "https://api.acme.nullify.ai", "queryParameters": {"bitbucketWorkspaceId": "w-1"}, "provisioningStatus": "ready"}, + {"name": "grp", "avatarUrl": "", "provider": {"id": "GitLab", "gitlab": {"groupId": 9}}, + "nullifyBaseUrl": "https://api.acme.nullify.ai", "queryParameters": {"gitlabGroupId": "9"}, "provisioningStatus": "ready"}, + {"name": "slack-only", "avatarUrl": "", "provider": {"id": "Slack", "slack": {}}, + "nullifyBaseUrl": "https://api.acme.nullify.ai", "queryParameters": {"slackTeamId": "T1"}, "provisioningStatus": "ready"} +]}` + +func TestListOrgsFromTheRouter(t *testing.T) { + var gotAuth string + calls := newRoutedAuthServer(t, func(w http.ResponseWriter, r *http.Request, host, path string) { + gotAuth = r.Header.Get("Authorization") + _, _ = w.Write([]byte(routerInstallations)) + }) + + list, err := ListOrgs(context.Background(), tenantHost, routerAPI, "router-token") + if err != nil { + t.Fatalf("ListOrgs: %v", err) + } + if len(*calls) != 1 || (*calls)[0] != (hostCall{routerAPI, "/admin/installations"}) { + t.Errorf("calls = %v, want one installations read on the router", *calls) + } + if gotAuth != "Bearer router-token" { + t.Errorf("Authorization = %q, want the router token", gotAuth) + } + + names := func(orgs []Org) string { + var out []string + for _, o := range orgs { + out = append(out, o.Name+"="+o.Key+":"+o.ID) + } + return strings.Join(out, ",") + } + if got, want := names(list.Orgs), "acme-payments=githubOwnerId:2002002,acme-web=githubOwnerId:1001001,contoso=azureOrganizationId:c-1,grp=gitlabGroupId:9,ws=bitbucketWorkspaceId:w-1"; got != want { + t.Errorf("orgs = %s\nwant %s", got, want) + } + if got, want := names(list.Elsewhere), "elsewhere-inc=githubOwnerId:666"; got != want { + t.Errorf("elsewhere = %s, want %s", got, want) + } + if got, want := names(list.Pending), "newco=githubOwnerId:555,pathy=githubOwnerId:777"; got != want { + t.Errorf("pending = %s, want %s", got, want) + } +} + +// A legacy stack lists its own installations without base URLs. +func TestListOrgsFromTheTenantItself(t *testing.T) { + newRoutedAuthServer(t, func(w http.ResponseWriter, _ *http.Request, host, _ string) { + if host != tenantAPI { + t.Errorf("listed on %s, want the tenant", host) + } + _, _ = w.Write([]byte(`{"installations": [{"name": "acme", "provider": {"id": "GitHub", "github": {}},` + + ` "queryParameters": {"githubOwnerId": "42"}}]}`)) + }) + + list, err := ListOrgs(context.Background(), tenantHost, tenantHost, "tok") + if err != nil { + t.Fatalf("ListOrgs: %v", err) + } + if len(list.Orgs) != 1 || list.Orgs[0].ID != "42" || list.Orgs[0].Host != tenantAPI { + t.Errorf("orgs = %v, want the tenant's own org", list.Orgs) + } +} + +func TestListOrgsFailsClosed(t *testing.T) { + tests := []struct { + name string + handler func(w http.ResponseWriter) + want string + }{ + {"unauthorized", func(w http.ResponseWriter) { w.WriteHeader(http.StatusUnauthorized) }, "status 401"}, + {"redirect is not followed", func(w http.ResponseWriter) { + w.Header().Set("Location", "https://api.evil.example/admin/installations") + w.WriteHeader(http.StatusFound) + }, "status 302"}, + {"garbage", func(w http.ResponseWriter) { _, _ = w.Write([]byte("")) }, "decode organizations"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + newRoutedAuthServer(t, func(w http.ResponseWriter, _ *http.Request, _, _ string) { tt.handler(w) }) + _, err := ListOrgs(context.Background(), tenantHost, routerAPI, "tok") + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("err = %v, want one containing %q", err, tt.want) + } + }) + } +} + +// An org on another tenant that the router still reports pending or +// unavailable cannot be chosen there, so it never replaces a working default. +func TestAnyHostSkipsAnUnusableOrgOnAnotherTenant(t *testing.T) { + ready := Org{Name: "beta-org", Key: "githubOwnerId", ID: "3003003", Host: "api.beta.nullify.ai", Status: "ready"} + held := Org{Name: "gamma-org", Key: "githubOwnerId", ID: "4004004", Host: "api.gamma.nullify.ai", Status: "pending"} + list := OrgList{Orgs: []Org{acmeWeb}, Elsewhere: []Org{ready, held}} + + org, err := ResolveOrg(tenantHost, list, OrgInputs{Flag: "beta-org", FlagSource: "--org", AnyHost: true}) + if err != nil || org.ID != "3003003" { + t.Fatalf("org = %v, %v; want beta-org", org, err) + } + if _, err := ResolveOrg(tenantHost, list, OrgInputs{Flag: "gamma-org", FlagSource: "--org", AnyHost: true}); err == nil || !strings.Contains(err.Error(), "not ready for use yet") { + t.Fatalf("err = %v, want gamma-org reported as not ready", err) + } + held.Status = "unavailable" + list.Elsewhere = []Org{ready, held} + if _, err := ResolveOrg(tenantHost, list, OrgInputs{Flag: "gamma-org", FlagSource: "--org", AnyHost: true}); err == nil || !strings.Contains(err.Error(), "not ready for use yet") { + t.Fatalf("err = %v, want gamma-org reported as not ready", err) + } +} diff --git a/internal/auth/router.go b/internal/auth/router.go index acaf89e..cc31b88 100644 --- a/internal/auth/router.go +++ b/internal/auth/router.go @@ -13,24 +13,28 @@ const RouterHost = "nullify.ai" // RouterAppURL is the dashboard URL for users who sign in through the router. const RouterAppURL = "https://app.nullify.ai" -var routerAliases = map[string]bool{ - "nullify.ai": true, - "api.nullify.ai": true, - "app.nullify.ai": true, +// Router is one Nullify router environment. Key is where its login is stored +// and the host commands name it by; AuthHost serves sign-in and the org list; +// a data host it lists must be api.