diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md index 8892eee5a178..9df69a7dc049 100644 --- a/CHANGELOG_PENDING.md +++ b/CHANGELOG_PENDING.md @@ -8,6 +8,9 @@ - Clear pending operations during `pulumi refresh` or `pulumi up -r`. [#8435](https://github.com/pulumi/pulumi/pull/8435) +- [cli] - `pulumi whoami --verbose` and `pulumi about` include a list of the current users organizations. + [#9211](https://github.com/pulumi/pulumi/pull/9211) + ### Bug Fixes - [codegen/go] - Fix Go SDK function output to check for errors diff --git a/pkg/backend/backend.go b/pkg/backend/backend.go index 01cbf0d48483..ef3927805716 100644 --- a/pkg/backend/backend.go +++ b/pkg/backend/backend.go @@ -200,8 +200,8 @@ type Backend interface { Logout() error // LogoutAll logs you out of all the backend and removes any stored credentials. LogoutAll() error - // Returns the identity of the current user for the backend. - CurrentUser() (string, error) + // Returns the identity of the current user and any organizations they are in for the backend. + CurrentUser() (string, []string, error) // Cancel the current update for the given stack. CancelCurrentUpdate(ctx context.Context, stackRef StackReference) error diff --git a/pkg/backend/filestate/backend.go b/pkg/backend/filestate/backend.go index a0e55d393909..211b1907467a 100644 --- a/pkg/backend/filestate/backend.go +++ b/pkg/backend/filestate/backend.go @@ -810,12 +810,12 @@ func (b *localBackend) LogoutAll() error { return workspace.DeleteAllAccounts() } -func (b *localBackend) CurrentUser() (string, error) { +func (b *localBackend) CurrentUser() (string, []string, error) { user, err := user.Current() if err != nil { - return "", err + return "", nil, err } - return user.Username, nil + return user.Username, nil, nil } func (b *localBackend) getLocalStacks() ([]tokens.Name, error) { diff --git a/pkg/backend/httpstate/backend.go b/pkg/backend/httpstate/backend.go index 9c395b7cabb5..881acf5e0e71 100644 --- a/pkg/backend/httpstate/backend.go +++ b/pkg/backend/httpstate/backend.go @@ -212,13 +212,18 @@ func loginWithBrowser(ctx context.Context, d diag.Sink, cloudURL string, opts di accessToken := <-c - username, err := client.NewClient(cloudURL, accessToken, d).GetPulumiAccountName(ctx) + username, organizations, err := client.NewClient(cloudURL, accessToken, d).GetPulumiAccountDetails(ctx) if err != nil { return nil, err } // Save the token and return the backend - account := workspace.Account{AccessToken: accessToken, Username: username, LastValidatedAt: time.Now()} + account := workspace.Account{ + AccessToken: accessToken, + Username: username, + Organizations: organizations, + LastValidatedAt: time.Now(), + } if err = workspace.StoreAccount(cloudURL, account, true); err != nil { return nil, err } @@ -237,9 +242,9 @@ func Login(ctx context.Context, d diag.Sink, cloudURL string, opts display.Optio existingAccount, err := workspace.GetAccount(cloudURL) if err == nil && existingAccount.AccessToken != "" { // If the account was last verified less than an hour ago, assume the token is valid. - valid, username := true, existingAccount.Username + valid, username, organizations := true, existingAccount.Username, existingAccount.Organizations if username == "" || existingAccount.LastValidatedAt.Add(1*time.Hour).Before(time.Now()) { - valid, username, err = IsValidAccessToken(ctx, cloudURL, existingAccount.AccessToken) + valid, username, organizations, err = IsValidAccessToken(ctx, cloudURL, existingAccount.AccessToken) if err != nil { return nil, err } @@ -249,6 +254,7 @@ func Login(ctx context.Context, d diag.Sink, cloudURL string, opts display.Optio if valid { // Save the token. While it hasn't changed this will update the current cloud we are logged into, as well. existingAccount.Username = username + existingAccount.Organizations = organizations if err = workspace.StoreAccount(cloudURL, existingAccount, true); err != nil { return nil, err } @@ -328,7 +334,7 @@ func Login(ctx context.Context, d diag.Sink, cloudURL string, opts display.Optio } // Try and use the credentials to see if they are valid. - valid, username, err := IsValidAccessToken(ctx, cloudURL, accessToken) + valid, username, organizations, err := IsValidAccessToken(ctx, cloudURL, accessToken) if err != nil { return nil, err } else if !valid { @@ -336,7 +342,12 @@ func Login(ctx context.Context, d diag.Sink, cloudURL string, opts display.Optio } // Save them. - account := workspace.Account{AccessToken: accessToken, Username: username, LastValidatedAt: time.Now()} + account := workspace.Account{ + AccessToken: accessToken, + Username: username, + Organizations: organizations, + LastValidatedAt: time.Now(), + } if err = workspace.StoreAccount(cloudURL, account, true); err != nil { return nil, err } @@ -389,28 +400,29 @@ func (b *cloudBackend) Name() string { } func (b *cloudBackend) URL() string { - user, err := b.CurrentUser() + user, _, err := b.CurrentUser() if err != nil { return cloudConsoleURL(b.url) } return cloudConsoleURL(b.url, user) } -func (b *cloudBackend) CurrentUser() (string, error) { +func (b *cloudBackend) CurrentUser() (string, []string, error) { return b.currentUser(context.Background()) } -func (b *cloudBackend) currentUser(ctx context.Context) (string, error) { +func (b *cloudBackend) currentUser(ctx context.Context) (string, []string, error) { account, err := workspace.GetAccount(b.CloudURL()) if err != nil { - return "", err + return "", nil, err } if account.Username != "" { logging.V(1).Infof("found username for access token") - return account.Username, nil + return account.Username, account.Organizations, nil } logging.V(1).Infof("no username for access token") - return b.client.GetPulumiAccountName(ctx) + name, orgs, err := b.client.GetPulumiAccountDetails(ctx) + return name, orgs, err } func (b *cloudBackend) CloudURL() string { return b.url } @@ -430,7 +442,7 @@ func (b *cloudBackend) parsePolicyPackReference(s string) (backend.PolicyPackRef } if orgName == "" { - currentUser, userErr := b.CurrentUser() + currentUser, _, userErr := b.CurrentUser() if userErr != nil { return nil, userErr } @@ -535,7 +547,7 @@ func (b *cloudBackend) ParseStackReference(s string) (backend.StackReference, er if defaultOrg != "" { qualifiedName.Owner = defaultOrg } else { - currentUser, userErr := b.CurrentUser() + currentUser, _, userErr := b.CurrentUser() if userErr != nil { return nil, userErr } @@ -674,7 +686,7 @@ func (b *cloudBackend) LogoutAll() error { // DoesProjectExist returns true if a project with the given name exists in this backend, or false otherwise. func (b *cloudBackend) DoesProjectExist(ctx context.Context, projectName string) (bool, error) { - owner, err := b.currentUser(ctx) + owner, _, err := b.currentUser(ctx) if err != nil { return false, err } @@ -1464,19 +1476,19 @@ func (b *cloudBackend) tryNextUpdate(ctx context.Context, update client.UpdateId // IsValidAccessToken tries to use the provided Pulumi access token and returns if it is accepted // or not. Returns error on any unexpected error. -func IsValidAccessToken(ctx context.Context, cloudURL, accessToken string) (bool, string, error) { +func IsValidAccessToken(ctx context.Context, cloudURL, accessToken string) (bool, string, []string, error) { // Make a request to get the authenticated user. If it returns a successful response, // we know the access token is legit. We also parse the response as JSON and confirm // it has a githubLogin field that is non-empty (like the Pulumi Service would return). - username, err := client.NewClient(cloudURL, accessToken, cmdutil.Diag()).GetPulumiAccountName(ctx) + username, organizations, err := client.NewClient(cloudURL, accessToken, cmdutil.Diag()).GetPulumiAccountDetails(ctx) if err != nil { if errResp, ok := err.(*apitype.ErrorResponse); ok && errResp.Code == 401 { - return false, "", nil + return false, "", nil, nil } - return false, "", fmt.Errorf("getting user info from %v: %w", cloudURL, err) + return false, "", nil, fmt.Errorf("getting user info from %v: %w", cloudURL, err) } - return true, username, nil + return true, username, organizations, nil } // UpdateStackTags updates the stacks's tags, replacing all existing tags. diff --git a/pkg/backend/httpstate/client/client.go b/pkg/backend/httpstate/client/client.go index 27cf727bc035..c34a43f7eb2d 100644 --- a/pkg/backend/httpstate/client/client.go +++ b/pkg/backend/httpstate/client/client.go @@ -45,6 +45,7 @@ type Client struct { apiURL string apiToken apiAccessToken apiUser string + apiOrgs []string diag diag.Sink client restClient } @@ -168,26 +169,50 @@ func getUpdatePath(update UpdateIdentifier, components ...string) string { return getStackPath(update.StackIdentifier, components...) } -type getUserResponse struct { +// Copied from https://github.com/pulumi/pulumi-service/blob/master/pkg/apitype/users.go#L7-L16 +type serviceUserInfo struct { + Name string `json:"name"` GitHubLogin string `json:"githubLogin"` + AvatarURL string `json:"avatarUrl"` + Email string `json:"email,omitempty"` +} + +// Copied from https://github.com/pulumi/pulumi-service/blob/master/pkg/apitype/users.go#L20-L34 +type serviceUser struct { + ID string `json:"id"` + GitHubLogin string `json:"githubLogin"` + Name string `json:"name"` + Email string `json:"email"` + AvatarURL string `json:"avatarUrl"` + Organizations []serviceUserInfo `json:"organizations"` + Identities []string `json:"identities"` + SiteAdmin *bool `json:"siteAdmin,omitempty"` } // GetPulumiAccountName returns the user implied by the API token associated with this client. -func (pc *Client) GetPulumiAccountName(ctx context.Context) (string, error) { +func (pc *Client) GetPulumiAccountDetails(ctx context.Context) (string, []string, error) { if pc.apiUser == "" { - resp := getUserResponse{} + resp := serviceUser{} if err := pc.restCall(ctx, "GET", "/api/user", nil, nil, &resp); err != nil { - return "", err + return "", nil, err } if resp.GitHubLogin == "" { - return "", errors.New("unexpected response from server") + return "", nil, errors.New("unexpected response from server") } pc.apiUser = resp.GitHubLogin + pc.apiOrgs = make([]string, len(resp.Organizations)) + for i, org := range resp.Organizations { + if org.GitHubLogin == "" { + return "", nil, errors.New("unexpected response from server") + } + + pc.apiOrgs[i] = org.GitHubLogin + } } - return pc.apiUser, nil + return pc.apiUser, pc.apiOrgs, nil } // GetCLIVersionInfo asks the service for information about versions of the CLI (the newest version as well as the diff --git a/pkg/backend/httpstate/stack.go b/pkg/backend/httpstate/stack.go index c17002a00116..b13341f6fc48 100644 --- a/pkg/backend/httpstate/stack.go +++ b/pkg/backend/httpstate/stack.go @@ -60,7 +60,7 @@ func (c cloudBackendReference) String() string { return string(c.name) } } else { - currentUser, userErr := c.b.CurrentUser() + currentUser, _, userErr := c.b.CurrentUser() if userErr == nil && c.owner == currentUser { return string(c.name) } diff --git a/pkg/backend/mock.go b/pkg/backend/mock.go index 4a1f440b8bed..efbb33846700 100644 --- a/pkg/backend/mock.go +++ b/pkg/backend/mock.go @@ -55,7 +55,7 @@ type MockBackend struct { ImportDeploymentF func(context.Context, Stack, *apitype.UntypedDeployment) error LogoutF func() error LogoutAllF func() error - CurrentUserF func() (string, error) + CurrentUserF func() (string, []string, error) PreviewF func(context.Context, Stack, UpdateOperation) (*deploy.Plan, engine.ResourceChanges, result.Result) UpdateF func(context.Context, Stack, @@ -319,7 +319,7 @@ func (be *MockBackend) LogoutAll() error { panic("not implemented") } -func (be *MockBackend) CurrentUser() (string, error) { +func (be *MockBackend) CurrentUser() (string, []string, error) { if be.CurrentUserF != nil { return be.CurrentUserF() } diff --git a/pkg/cmd/pulumi/about.go b/pkg/cmd/pulumi/about.go index 1d04b9c3c5cb..05c8da978243 100644 --- a/pkg/cmd/pulumi/about.go +++ b/pkg/cmd/pulumi/about.go @@ -280,22 +280,22 @@ func (host hostAbout) String() string { } type backendAbout struct { - Name string `json:"name"` - URL string `json:"url"` - User string `json:"user"` + Name string `json:"name"` + URL string `json:"url"` + User string `json:"user"` + Organizations []string `json:"organizations"` } func getBackendAbout(b backend.Backend) backendAbout { - var err error - var currentUser string - currentUser, err = b.CurrentUser() + currentUser, currentOrgs, err := b.CurrentUser() if err != nil { currentUser = "Unknown" } return backendAbout{ - Name: b.Name(), - URL: b.URL(), - User: currentUser, + Name: b.Name(), + URL: b.URL(), + User: currentUser, + Organizations: currentOrgs, } } @@ -306,6 +306,7 @@ func (b backendAbout) String() string { {"Name", b.Name}, {"URL", b.URL}, {"User", b.User}, + {"Organizations", strings.Join(b.Organizations, ", ")}, }), }.String() } diff --git a/pkg/cmd/pulumi/login.go b/pkg/cmd/pulumi/login.go index 02a73b0f13a4..04348e101325 100644 --- a/pkg/cmd/pulumi/login.go +++ b/pkg/cmd/pulumi/login.go @@ -151,7 +151,7 @@ func newLoginCmd() *cobra.Command { return fmt.Errorf("problem logging in: %w", err) } - if currentUser, err := be.CurrentUser(); err == nil { + if currentUser, _, err := be.CurrentUser(); err == nil { fmt.Printf("Logged in to %s as %s (%s)\n", be.Name(), currentUser, be.URL()) } else { fmt.Printf("Logged in to %s (%s)\n", be.Name(), be.URL()) diff --git a/pkg/cmd/pulumi/new_test.go b/pkg/cmd/pulumi/new_test.go index 75540a0bdbab..888e94df1140 100644 --- a/pkg/cmd/pulumi/new_test.go +++ b/pkg/cmd/pulumi/new_test.go @@ -878,7 +878,7 @@ func loadProject(t *testing.T, dir string) *workspace.Project { func currentUser(t *testing.T) string { b, err := currentBackend(display.Options{}) assert.NoError(t, err) - currentUser, err := b.CurrentUser() + currentUser, _, err := b.CurrentUser() assert.NoError(t, err) return currentUser } diff --git a/pkg/cmd/pulumi/policy_group_ls.go b/pkg/cmd/pulumi/policy_group_ls.go index 95102b1f1bb0..4a6fa7a6956c 100644 --- a/pkg/cmd/pulumi/policy_group_ls.go +++ b/pkg/cmd/pulumi/policy_group_ls.go @@ -57,7 +57,7 @@ func newPolicyGroupLsCmd() *cobra.Command { if len(cliArgs) > 0 { orgName = cliArgs[0] } else { - orgName, err = b.CurrentUser() + orgName, _, err = b.CurrentUser() if err != nil { return err } diff --git a/pkg/cmd/pulumi/policy_ls.go b/pkg/cmd/pulumi/policy_ls.go index 8813eb304e4f..742d597d28fb 100644 --- a/pkg/cmd/pulumi/policy_ls.go +++ b/pkg/cmd/pulumi/policy_ls.go @@ -47,7 +47,7 @@ func newPolicyLsCmd() *cobra.Command { if len(cliArgs) > 0 { orgName = cliArgs[0] } else { - orgName, err = b.CurrentUser() + orgName, _, err = b.CurrentUser() if err != nil { return err } diff --git a/pkg/cmd/pulumi/whoami.go b/pkg/cmd/pulumi/whoami.go index d171e58eb135..8d5b0b3bf769 100644 --- a/pkg/cmd/pulumi/whoami.go +++ b/pkg/cmd/pulumi/whoami.go @@ -16,6 +16,7 @@ package main import ( "fmt" + "strings" "github.com/pulumi/pulumi/pkg/v3/backend/display" "github.com/pulumi/pulumi/sdk/v3/go/common/util/cmdutil" @@ -42,13 +43,14 @@ func newWhoAmICmd() *cobra.Command { return err } - name, err := b.CurrentUser() + name, orgs, err := b.CurrentUser() if err != nil { return err } if verbose { fmt.Printf("User: %s\n", name) + fmt.Printf("Organizations: %s\n", strings.Join(orgs, ", ")) fmt.Printf("Backend URL: %s\n", b.URL()) } else { fmt.Println(name) diff --git a/sdk/go/common/workspace/creds.go b/sdk/go/common/workspace/creds.go index d5e98240ce3b..0f3e048d3dde 100644 --- a/sdk/go/common/workspace/creds.go +++ b/sdk/go/common/workspace/creds.go @@ -113,6 +113,7 @@ func StoreAccount(key string, account Account, current bool) error { type Account struct { AccessToken string `json:"accessToken,omitempty"` // The access token for this account. Username string `json:"username,omitempty"` // The username for this account. + Organizations []string `json:"organizations,omitempty"` // The organizations for this account. LastValidatedAt time.Time `json:"lastValidatedAt,omitempty"` // The last time this token was validated. }