diff --git a/cmd/root.go b/cmd/root.go index 5d06d1ff..95ab90eb 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,6 +23,7 @@ import ( memberscmd "github.com/launchdarkly/ldcli/cmd/members" resourcecmd "github.com/launchdarkly/ldcli/cmd/resources" sourcemapscmd "github.com/launchdarkly/ldcli/cmd/sourcemaps" + whoamicmd "github.com/launchdarkly/ldcli/cmd/whoami" "github.com/launchdarkly/ldcli/internal/analytics" "github.com/launchdarkly/ldcli/internal/config" "github.com/launchdarkly/ldcli/internal/dev_server" @@ -100,6 +101,7 @@ func NewRootCommand( "config", "help", "login", + "whoami", } { if cmd.HasParent() && cmd.Parent().Name() == name { cmd.DisableFlagParsing = true @@ -212,6 +214,7 @@ func NewRootCommand( cmd.AddCommand(resourcecmd.NewResourcesCmd()) cmd.AddCommand(devcmd.NewDevServerCmd(clients.ResourcesClient, analyticsTrackerFn, clients.DevClient)) cmd.AddCommand(sourcemapscmd.NewSourcemapsCmd(clients.ResourcesClient, analyticsTrackerFn)) + cmd.AddCommand(whoamicmd.NewWhoAmICmd(clients.ResourcesClient)) resourcecmd.AddAllResourceCmds(cmd, clients.ResourcesClient, analyticsTrackerFn) // add non-generated commands diff --git a/cmd/whoami/whoami.go b/cmd/whoami/whoami.go new file mode 100644 index 00000000..bb30b57f --- /dev/null +++ b/cmd/whoami/whoami.go @@ -0,0 +1,155 @@ +package whoami + +import ( + "encoding/json" + "fmt" + "net/url" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/launchdarkly/ldcli/cmd/cliflags" + resourcescmd "github.com/launchdarkly/ldcli/cmd/resources" + "github.com/launchdarkly/ldcli/internal/errors" + "github.com/launchdarkly/ldcli/internal/output" + "github.com/launchdarkly/ldcli/internal/resources" +) + +type callerIdentity struct { + AccountID string `json:"accountId"` + AuthKind string `json:"authKind"` + ClientID string `json:"clientId"` + EnvironmentID string `json:"environmentId"` + EnvironmentName string `json:"environmentName"` + MemberID string `json:"memberId"` + ProjectID string `json:"projectId"` + ProjectName string `json:"projectName"` + Scopes []string `json:"scopes"` + ServiceToken bool `json:"serviceToken"` + TokenID string `json:"tokenId"` + TokenKind string `json:"tokenKind"` + TokenName string `json:"tokenName"` +} + +type memberSummary struct { + Email string `json:"email"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Role string `json:"role"` +} + +func NewWhoAmICmd(client resources.Client) *cobra.Command { + cmd := &cobra.Command{ + Args: cobra.NoArgs, + Long: "Show information about the identity associated with the current access token.", + RunE: makeRequest(client), + Short: "Show current caller identity", + Use: "whoami", + } + + cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) + + // Hide flags that don't apply to whoami from its help output. + // Access token and base URI are read from config; analytics opt-out is not relevant. + hiddenInHelp := []string{ + cliflags.AccessTokenFlag, + cliflags.BaseURIFlag, + cliflags.AnalyticsOptOut, + } + defaultHelp := cmd.HelpFunc() + cmd.SetHelpFunc(func(c *cobra.Command, args []string) { + for _, name := range hiddenInHelp { + if f := c.Root().PersistentFlags().Lookup(name); f != nil { + f.Hidden = true + } + } + defaultHelp(c, args) + for _, name := range hiddenInHelp { + if f := c.Root().PersistentFlags().Lookup(name); f != nil { + f.Hidden = false + } + } + }) + + return cmd +} + +func makeRequest(client resources.Client) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + accessToken := viper.GetString(cliflags.AccessTokenFlag) + if accessToken == "" { + return errors.NewError("no access token configured. Run `ldcli login` or set LD_ACCESS_TOKEN") + } + + baseURI := viper.GetString(cliflags.BaseURIFlag) + outputKind := cliflags.GetOutputKind(cmd) + + identityPath, _ := url.JoinPath(baseURI, "api/v2/caller-identity") + identityRes, err := client.MakeRequest(accessToken, "GET", identityPath, "application/json", nil, nil, false) + if err != nil { + return output.NewCmdOutputError(err, outputKind) + } + + // For JSON output, return the raw caller-identity response. + if outputKind == "json" { + out, err := output.CmdOutputSingular(outputKind, identityRes, output.ConfigPlaintextOutputFn) + if err != nil { + return errors.NewError(err.Error()) + } + fmt.Fprint(cmd.OutOrStdout(), out+"\n") + return nil + } + + var identity callerIdentity + if err := json.Unmarshal(identityRes, &identity); err != nil { + return errors.NewError(err.Error()) + } + + // Fetch member info for a richer plaintext display. + var member *memberSummary + if identity.MemberID != "" { + memberPath, _ := url.JoinPath(baseURI, "api/v2/members", identity.MemberID) + memberRes, err := client.MakeRequest(accessToken, "GET", memberPath, "application/json", nil, nil, false) + if err == nil { + var m memberSummary + if json.Unmarshal(memberRes, &m) == nil { + member = &m + } + } + } + + fmt.Fprint(cmd.OutOrStdout(), formatPlaintext(identity, member)+"\n") + return nil + } +} + +func formatPlaintext(identity callerIdentity, member *memberSummary) string { + var sb strings.Builder + + if member != nil { + name := strings.TrimSpace(member.FirstName + " " + member.LastName) + if name != "" { + fmt.Fprintf(&sb, "%s <%s>\n", name, member.Email) + } else { + fmt.Fprintf(&sb, "%s\n", member.Email) + } + fmt.Fprintf(&sb, "Role: %s\n", member.Role) + } + + tokenKind := identity.TokenKind + if identity.ServiceToken { + tokenKind = "service token" + } + if identity.TokenName != "" { + fmt.Fprintf(&sb, "Token: %s (%s)\n", identity.TokenName, tokenKind) + } else if identity.ClientID != "" { + fmt.Fprintf(&sb, "Token: %s (%s)\n", identity.ClientID, tokenKind) + } + + if identity.AccountID != "" { + fmt.Fprintf(&sb, "Account: %s\n", identity.AccountID) + } + + return strings.TrimRight(sb.String(), "\n") +} diff --git a/cmd/whoami/whoami_test.go b/cmd/whoami/whoami_test.go new file mode 100644 index 00000000..5e8866ba --- /dev/null +++ b/cmd/whoami/whoami_test.go @@ -0,0 +1,107 @@ +package whoami_test + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/cmd" + "github.com/launchdarkly/ldcli/internal/analytics" + "github.com/launchdarkly/ldcli/internal/resources" +) + +// sequentialMockClient returns responses in order, one per call. +type sequentialMockClient struct { + responses [][]byte + callIndex int +} + +var _ resources.Client = &sequentialMockClient{} + +func (c *sequentialMockClient) MakeRequest(_, _, _ string, _ string, _ url.Values, _ []byte, _ bool) ([]byte, error) { + if c.callIndex >= len(c.responses) { + return nil, nil + } + res := c.responses[c.callIndex] + c.callIndex++ + return res, nil +} + +func (c *sequentialMockClient) MakeUnauthenticatedRequest(_ string, _ string, _ []byte) ([]byte, error) { + return nil, nil +} + +func TestWhoAmI(t *testing.T) { + t.Run("shows member name, email, role, and token", func(t *testing.T) { + mockClient := &sequentialMockClient{ + responses: [][]byte{ + []byte(`{"memberId": "abc123", "tokenName": "my-token", "tokenKind": "personal", "accountId": "acct1"}`), + []byte(`{"_id": "abc123", "email": "ariel@acme.com", "firstName": "Ariel", "lastName": "Flores", "role": "admin"}`), + }, + } + + t.Setenv("LD_ACCESS_TOKEN", "abcd1234") + + output, err := cmd.CallCmd( + t, + cmd.APIClients{ResourcesClient: mockClient}, + analytics.NoopClientFn{}.Tracker(), + []string{"whoami"}, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), "Ariel Flores ") + assert.Contains(t, string(output), "Role: admin") + assert.Contains(t, string(output), "Token: my-token (personal)") + }) + + t.Run("without member ID shows token info only", func(t *testing.T) { + mockClient := &resources.MockClient{ + Response: []byte(`{"tokenName": "sdk-key", "tokenKind": "server", "accountId": "acct1"}`), + } + + t.Setenv("LD_ACCESS_TOKEN", "abcd1234") + + output, err := cmd.CallCmd( + t, + cmd.APIClients{ResourcesClient: mockClient}, + analytics.NoopClientFn{}.Tracker(), + []string{"whoami"}, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), "Token: sdk-key (server)") + assert.NotContains(t, string(output), "Role:") + }) + + t.Run("without configured token returns helpful error", func(t *testing.T) { + _, err := cmd.CallCmd( + t, + cmd.APIClients{}, + analytics.NoopClientFn{}.Tracker(), + []string{"whoami"}, + ) + + require.ErrorContains(t, err, "no access token configured") + }) + + t.Run("with --output json returns raw caller-identity JSON", func(t *testing.T) { + mockClient := &resources.MockClient{ + Response: []byte(`{"tokenName": "my-token", "memberId": "abc123"}`), + } + + t.Setenv("LD_ACCESS_TOKEN", "abcd1234") + + output, err := cmd.CallCmd( + t, + cmd.APIClients{ResourcesClient: mockClient}, + analytics.NoopClientFn{}.Tracker(), + []string{"whoami", "--output", "json"}, + ) + + require.NoError(t, err) + assert.Contains(t, string(output), `"tokenName": "my-token"`) + }) +}