Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -100,6 +101,7 @@ func NewRootCommand(
"config",
"help",
"login",
"whoami",
} {
if cmd.HasParent() && cmd.Parent().Name() == name {
cmd.DisableFlagParsing = true
Expand Down Expand Up @@ -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
Expand Down
155 changes: 155 additions & 0 deletions cmd/whoami/whoami.go
Original file line number Diff line number Diff line change
@@ -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")
}
107 changes: 107 additions & 0 deletions cmd/whoami/whoami_test.go
Original file line number Diff line number Diff line change
@@ -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 <ariel@acme.com>")
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"`)
})
}
Loading