Skip to content

Commit

Permalink
Merge pull request #46 from hashicorp/f-sp-auto-configure
Browse files Browse the repository at this point in the history
Auto-configure Org/Project ID for SPs
  • Loading branch information
dadgar authored Mar 26, 2024
2 parents d7c3151 + ea8d2bf commit 0de1805
Show file tree
Hide file tree
Showing 6 changed files with 273 additions and 60 deletions.
3 changes: 3 additions & 0 deletions .changelog/46.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
```release-note:improvement
auth: If authenticating as a service principal, automatically populate the profile with the organization and project ID. This allows using the CLI without instantiating the profile.
```
10 changes: 5 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,13 @@ REGISTRY_NAME?=docker.io/hashicorp
IMAGE_NAME=hcp
IMAGE_TAG_DEV?=$(REGISTRY_NAME)/$(IMAGE_NAME):latest-$(shell git rev-parse --short HEAD)
DEV_DOCKER_GOOS ?= linux
DEV_DOCKER_GOARCH ?= amd64
DEV_DOCKER_GOARCH ?= arm64

.PHONY: docker-build-dev
.PHONY: docker/dev
# Builds from the locally generated binary in ./bin/
docker-build-dev: export GOOS=$(DEV_DOCKER_GOOS)
docker-build-dev: export GOARCH=$(DEV_DOCKER_GOARCH)
docker-build-dev: build
docker/dev: export GOOS=$(DEV_DOCKER_GOOS)
docker/dev: export GOARCH=$(DEV_DOCKER_GOARCH)
docker/dev: go/build
docker buildx build \
--load \
--platform $(DEV_DOCKER_GOOS)/$(DEV_DOCKER_GOARCH) \
Expand Down
69 changes: 67 additions & 2 deletions internal/commands/auth/login.go
Original file line number Diff line number Diff line change
@@ -1,28 +1,50 @@
package auth

import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"

"github.com/hashicorp/hcp-sdk-go/auth"
"github.com/hashicorp/hcp-sdk-go/clients/cloud-iam/stable/2019-12-10/client/iam_service"
hcpconf "github.com/hashicorp/hcp-sdk-go/config"
"github.com/hashicorp/hcp-sdk-go/httpclient"
hcpAuth "github.com/hashicorp/hcp/internal/pkg/auth"
"github.com/hashicorp/hcp/internal/pkg/cmd"
"github.com/hashicorp/hcp/internal/pkg/flagvalue"
"github.com/hashicorp/hcp/internal/pkg/heredoc"
"github.com/hashicorp/hcp/internal/pkg/iostreams"
"github.com/hashicorp/hcp/internal/pkg/profile"
"github.com/hashicorp/hcp/version"
"github.com/mitchellh/go-homedir"
"github.com/posener/complete"
)

func NewCmdLogin(ctx *cmd.Context) *cmd.Command {
opts := &LoginOpts{
IO: ctx.IO,
Profile: ctx.Profile,
Ctx: ctx.ShutdownCtx,
IO: ctx.IO,
Profile: ctx.Profile,
GetIAM: func(c hcpconf.HCPConfig) (iam_service.ClientService, error) {

// Create a new HCP client since the one passed through the
// cmd.Context may not be authenticated with the same principal that the
// login command will be.
hconfig := httpclient.Config{
HCPConfig: c,
SourceChannel: version.GetSourceChannel(),
}

hcpClient, err := httpclient.New(hconfig)
if err != nil {
return nil, fmt.Errorf("failed to create HCP client: %w", err)
}

return iam_service.New(hcpClient, nil), nil
},
ConfigFn: hcpconf.NewHCPConfig,
CredentialDir: hcpAuth.CredentialsDir,
}
Expand Down Expand Up @@ -100,11 +122,19 @@ func NewCmdLogin(ctx *cmd.Context) *cmd.Command {
// NewConfigFunc is the function definition for retrieving a new HCPConfig
type NewConfigFunc func(opts ...hcpconf.HCPConfigOption) (hcpconf.HCPConfig, error)

// GetIAMClientFunc is the function definition for retrieving an IAM service client
// from a HCP Config.
type GetIAMClientFunc func(c hcpconf.HCPConfig) (iam_service.ClientService, error)

type LoginOpts struct {
Ctx context.Context
IO iostreams.IOStreams
Profile *profile.Profile
Quiet bool

// GetIAM retrieves an IAM service client using the passed HCP Config.
GetIAM GetIAMClientFunc

// ConfigFn is used to retrieve a new HCP Config
ConfigFn NewConfigFunc

Expand Down Expand Up @@ -172,6 +202,41 @@ func loginRun(opts *LoginOpts) error {
}
}

// If there is no organization or project set in the profile, attempt to
// default it if the logging in principal is a service principal.
if opts.Profile.OrganizationID == "" || opts.Profile.ProjectID == "" {
// Get an IAM client using the new hcpConfig
iam, err := opts.GetIAM(hcpConfig)
if err != nil {
return fmt.Errorf("failed to create IAM client: %w", err)
}

// Get the caller identity. If it is a service principal, we can set the
// organization and potentially project automatically.
callerIdentityParams := iam_service.NewIamServiceGetCallerIdentityParamsWithContext(opts.Ctx)
ident, err := iam.IamServiceGetCallerIdentity(callerIdentityParams, nil)
if err != nil {
return fmt.Errorf("failed to get identity of principal logging in: %w", err)
}

didUpdate := false
isSP := ident.Payload != nil && ident.Payload.Principal != nil && ident.Payload.Principal.Service != nil
if opts.Profile.OrganizationID == "" && isSP {
opts.Profile.OrganizationID = ident.Payload.Principal.Service.OrganizationID
didUpdate = true
}
if opts.Profile.ProjectID == "" && isSP && ident.Payload.Principal.Service.ProjectID != "" {
opts.Profile.ProjectID = ident.Payload.Principal.Service.ProjectID
didUpdate = true
}

if didUpdate {
if err := opts.Profile.Write(); err != nil {
return fmt.Errorf("failed to update profile: %w", err)
}
}
}

cs := opts.IO.ColorScheme()
if !opts.Quiet {
fmt.Fprintln(opts.IO.Err(), cs.String("Successfully logged in!").Bold().Color(cs.Green()))
Expand Down
106 changes: 105 additions & 1 deletion internal/commands/auth/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,29 @@ import (
"testing"

"github.com/hashicorp/hcp/internal/commands/auth/mocks"
mock_iam_service "github.com/hashicorp/hcp/internal/pkg/api/mocks/github.com/hashicorp/hcp-sdk-go/clients/cloud-iam/stable/2019-12-10/client/iam_service"
hcpAuth "github.com/hashicorp/hcp/internal/pkg/auth"
"github.com/hashicorp/hcp/internal/pkg/iostreams"
"github.com/hashicorp/hcp/internal/pkg/profile"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"

"github.com/hashicorp/hcp-sdk-go/auth"
"github.com/hashicorp/hcp-sdk-go/auth/workload"
"github.com/hashicorp/hcp-sdk-go/clients/cloud-iam/stable/2019-12-10/client/iam_service"
"github.com/hashicorp/hcp-sdk-go/clients/cloud-iam/stable/2019-12-10/models"
hcpconf "github.com/hashicorp/hcp-sdk-go/config"
)

// getIAMClientFunc returns a getIAMClientFunc and a mock IAM client and returns
// the mock IAM client for use in tests.
func getIAMClientFunc(t *testing.T) (GetIAMClientFunc, *mock_iam_service.MockClientService) {
client := mock_iam_service.NewMockClientService(t)
return func(_ hcpconf.HCPConfig) (iam_service.ClientService, error) {
return client, nil
}, client
}

func TestLoginOpts_Validate(t *testing.T) {
t.Parallel()
r := require.New(t)
Expand Down Expand Up @@ -56,17 +69,35 @@ func TestLogin_Browser(t *testing.T) {
return m, nil
}

getter, iamClient := getIAMClientFunc(t)
io := iostreams.Test()
o := &LoginOpts{
IO: io,
Profile: profile.TestProfile(t),
GetIAM: getter,
ConfigFn: newHCP,
CredentialDir: t.TempDir(),
}

// Expect the mock to be called
m.EXPECT().Token().Return(nil, nil).Twice()

// Expect the IAM client to be called
iamClient.EXPECT().IamServiceGetCallerIdentity(mock.Anything, mock.Anything).
Return(&iam_service.IamServiceGetCallerIdentityOK{
Payload: &models.HashicorpCloudIamGetCallerIdentityResponse{
Principal: &models.HashicorpCloudIamPrincipal{
ID: "123",
Type: models.HashicorpCloudIamPrincipalTypePRINCIPALTYPEUSER.Pointer(),
User: &models.HashicorpCloudIamUserPrincipal{
Email: "[email protected]",
FullName: "Foo",
ID: "123",
},
},
},
}, nil)

// Run the command
r.NoError(loginRun(o))
r.Contains(io.Error.String(), "Successfully logged in!")
Expand All @@ -80,17 +111,38 @@ func TestLogin_Browser(t *testing.T) {

func TestLogin_SP(t *testing.T) {
t.Parallel()
t.Run("profile has org/project", func(t *testing.T) {
t.Parallel()
testLoginSP(t, true)
})

t.Run("profile does not have org/project", func(t *testing.T) {
t.Parallel()
testLoginSP(t, false)
})

}

func testLoginSP(t *testing.T, profilePreconfigured bool) {
r := require.New(t)

m := mocks.NewMockHCPConfig(t)
newHCP := func(opts ...hcpconf.HCPConfigOption) (hcpconf.HCPConfig, error) {
return m, nil
}

p := profile.TestProfile(t)
if profilePreconfigured {
p.OrganizationID = "preconfigured-org"
p.ProjectID = "preconfigured-project"
}

getter, iamClient := getIAMClientFunc(t)
io := iostreams.Test()
o := &LoginOpts{
IO: io,
Profile: profile.TestProfile(t),
Profile: p,
GetIAM: getter,
ConfigFn: newHCP,
CredentialDir: t.TempDir(),

Expand All @@ -101,6 +153,25 @@ func TestLogin_SP(t *testing.T) {
// Expect the mock to be called
m.EXPECT().Token().Return(nil, nil)

// Expect the IAM client to be called
orgID, projectID := "org-123", "project-456"
if !profilePreconfigured {
iamClient.EXPECT().IamServiceGetCallerIdentity(mock.Anything, mock.Anything).
Return(&iam_service.IamServiceGetCallerIdentityOK{
Payload: &models.HashicorpCloudIamGetCallerIdentityResponse{
Principal: &models.HashicorpCloudIamPrincipal{
ID: "123",
Type: models.HashicorpCloudIamPrincipalTypePRINCIPALTYPESERVICE.Pointer(),
Service: &models.HashicorpCloudIamServicePrincipal{
ID: "123",
OrganizationID: orgID,
ProjectID: projectID,
},
},
},
}, nil)
}

// Run the command
r.NoError(loginRun(o))
r.Contains(io.Error.String(), "Successfully logged in!")
Expand All @@ -114,6 +185,15 @@ func TestLogin_SP(t *testing.T) {
r.Equal(auth.CredentialFileSchemeServicePrincipal, credFile.Scheme)
r.Equal(o.ClientID, credFile.Oauth.ClientID)
r.Equal(o.ClientSecret, credFile.Oauth.ClientSecret)

// Check the profile
if profilePreconfigured {
r.NotEqual(orgID, o.Profile.OrganizationID)
r.NotEqual(projectID, o.Profile.ProjectID)
} else {
r.Equal(orgID, o.Profile.OrganizationID)
r.Equal(projectID, o.Profile.ProjectID)
}
}

func TestLogin_CredFile(t *testing.T) {
Expand Down Expand Up @@ -145,10 +225,12 @@ func TestLogin_CredFile(t *testing.T) {
}
r.NoError(json.NewEncoder(f).Encode(&credFile))

getter, iamClient := getIAMClientFunc(t)
io := iostreams.Test()
o := &LoginOpts{
IO: io,
Profile: profile.TestProfile(t),
GetIAM: getter,
ConfigFn: newHCP,
CredentialDir: t.TempDir(),
CredentialFile: f.Name(),
Expand All @@ -157,6 +239,23 @@ func TestLogin_CredFile(t *testing.T) {
// Expect the mock to be called
m.EXPECT().Token().Return(nil, nil)

// Expect the IAM client to be called
orgID, projectID := "org-123", "project-456"
iamClient.EXPECT().IamServiceGetCallerIdentity(mock.Anything, mock.Anything).
Return(&iam_service.IamServiceGetCallerIdentityOK{
Payload: &models.HashicorpCloudIamGetCallerIdentityResponse{
Principal: &models.HashicorpCloudIamPrincipal{
ID: "123",
Type: models.HashicorpCloudIamPrincipalTypePRINCIPALTYPESERVICE.Pointer(),
Service: &models.HashicorpCloudIamServicePrincipal{
ID: "123",
OrganizationID: orgID,
ProjectID: projectID,
},
},
},
}, nil)

// Run the command
r.NoError(loginRun(o))
r.Contains(io.Error.String(), "Successfully logged in!")
Expand All @@ -171,6 +270,11 @@ func TestLogin_CredFile(t *testing.T) {
var copied auth.CredentialFile
r.NoError(json.NewDecoder(copiedCF).Decode(&copied))
r.EqualValues(credFile, copied)

// Check the profile
r.Equal(orgID, o.Profile.OrganizationID)
r.Equal(projectID, o.Profile.ProjectID)

}

func Test_getHCPConfig(t *testing.T) {
Expand Down
Loading

0 comments on commit 0de1805

Please sign in to comment.