Merge pull request #448 from docker/azure_sp_login

Add options to `docker login azure` to support Service Principal login. Use it in E2E tests
This commit is contained in:
Guillaume Tardif 2020-08-11 16:22:47 +02:00 committed by GitHub
commit ba6e8045b2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 129 additions and 18 deletions

View File

@ -65,6 +65,18 @@ type ContextParams struct {
// LoginParams azure login options // LoginParams azure login options
type LoginParams struct { type LoginParams struct {
TenantID string TenantID string
ClientID string
ClientSecret string
}
// Validate returns an error if options are not used properly
func (opts LoginParams) Validate() error {
if opts.ClientID != "" || opts.ClientSecret != "" {
if opts.ClientID == "" || opts.ClientSecret == "" || opts.TenantID == "" {
return errors.New("for Service Principal login, 3 options must be specified: --client-id, --client-secret and --tenant-id")
}
}
return nil
} }
func init() { func init() {
@ -377,12 +389,18 @@ func (cs *aciComposeService) Logs(ctx context.Context, opts cli.ProjectOptions)
} }
type aciCloudService struct { type aciCloudService struct {
loginService *login.AzureLoginService loginService login.AzureLoginServiceAPI
} }
func (cs *aciCloudService) Login(ctx context.Context, params interface{}) error { func (cs *aciCloudService) Login(ctx context.Context, params interface{}) error {
createOpts := params.(LoginParams) opts, ok := params.(LoginParams)
return cs.loginService.Login(ctx, createOpts.TenantID) if !ok {
return errors.New("Could not read azure LoginParams struct from generic parameter")
}
if opts.ClientID != "" {
return cs.loginService.LoginServicePrincipal(opts.ClientID, opts.ClientSecret, opts.TenantID)
}
return cs.loginService.Login(ctx, opts.TenantID)
} }
func (cs *aciCloudService) Logout(ctx context.Context) error { func (cs *aciCloudService) Logout(ctx context.Context) error {

View File

@ -20,9 +20,10 @@ import (
"context" "context"
"testing" "testing"
"github.com/docker/api/containers" "github.com/stretchr/testify/mock"
"gotest.tools/v3/assert" "gotest.tools/v3/assert"
"github.com/docker/api/containers"
) )
func TestGetContainerName(t *testing.T) { func TestGetContainerName(t *testing.T) {
@ -58,3 +59,86 @@ func TestVerifyCommand(t *testing.T) {
assert.Error(t, err, "ACI exec command does not accept arguments to the command. "+ assert.Error(t, err, "ACI exec command does not accept arguments to the command. "+
"Only the binary should be specified") "Only the binary should be specified")
} }
func TestLoginParamsValidate(t *testing.T) {
err := LoginParams{
ClientID: "someID",
}.Validate()
assert.Error(t, err, "for Service Principal login, 3 options must be specified: --client-id, --client-secret and --tenant-id")
err = LoginParams{
ClientSecret: "someSecret",
}.Validate()
assert.Error(t, err, "for Service Principal login, 3 options must be specified: --client-id, --client-secret and --tenant-id")
err = LoginParams{}.Validate()
assert.NilError(t, err)
err = LoginParams{
TenantID: "tenant",
}.Validate()
assert.NilError(t, err)
}
func TestLoginServicePrincipal(t *testing.T) {
loginService := mockLoginService{}
loginService.On("LoginServicePrincipal", "someID", "secret", "tenant").Return(nil)
loginBackend := aciCloudService{
loginService: &loginService,
}
err := loginBackend.Login(context.Background(), LoginParams{
ClientID: "someID",
ClientSecret: "secret",
TenantID: "tenant",
})
assert.NilError(t, err)
}
func TestLoginWithTenant(t *testing.T) {
loginService := mockLoginService{}
ctx := context.Background()
loginService.On("Login", ctx, "tenant").Return(nil)
loginBackend := aciCloudService{
loginService: &loginService,
}
err := loginBackend.Login(ctx, LoginParams{
TenantID: "tenant",
})
assert.NilError(t, err)
}
func TestLoginWithoutTenant(t *testing.T) {
loginService := mockLoginService{}
ctx := context.Background()
loginService.On("Login", ctx, "").Return(nil)
loginBackend := aciCloudService{
loginService: &loginService,
}
err := loginBackend.Login(ctx, LoginParams{})
assert.NilError(t, err)
}
type mockLoginService struct {
mock.Mock
}
func (s *mockLoginService) Login(ctx context.Context, requestedTenantID string) error {
args := s.Called(ctx, requestedTenantID)
return args.Error(0)
}
func (s *mockLoginService) LoginServicePrincipal(clientID string, clientSecret string, tenantID string) error {
args := s.Called(clientID, clientSecret, tenantID)
return args.Error(0)
}
func (s *mockLoginService) Logout(ctx context.Context) error {
args := s.Called(ctx)
return args.Error(0)
}

View File

@ -72,6 +72,13 @@ type AzureLoginService struct {
apiHelper apiHelper apiHelper apiHelper
} }
// AzureLoginServiceAPI interface for Azure login service
type AzureLoginServiceAPI interface {
LoginServicePrincipal(clientID string, clientSecret string, tenantID string) error
Login(ctx context.Context, requestedTenantID string) error
Logout(ctx context.Context) error
}
const tokenStoreFilename = "dockerAccessToken.json" const tokenStoreFilename = "dockerAccessToken.json"
// NewAzureLoginService creates a NewAzureLoginService // NewAzureLoginService creates a NewAzureLoginService
@ -90,9 +97,9 @@ func newAzureLoginServiceFromPath(tokenStorePath string, helper apiHelper) (*Azu
}, nil }, nil
} }
// TestLoginFromServicePrincipal login with clientId / clientSecret from a previously created service principal. // LoginServicePrincipal login with clientId / clientSecret from a service principal.
// The resulting token does not include a refresh token, used for tests only // The resulting token does not include a refresh token
func (login *AzureLoginService) TestLoginFromServicePrincipal(clientID string, clientSecret string, tenantID string) error { func (login *AzureLoginService) LoginServicePrincipal(clientID string, clientSecret string, tenantID string) error {
// Tried with auth2.NewUsernamePasswordConfig() but could not make this work with username / password, setting this for CI with clientID / clientSecret // Tried with auth2.NewUsernamePasswordConfig() but could not make this work with username / password, setting this for CI with clientID / clientSecret
creds := auth2.NewClientCredentialsConfig(clientID, clientSecret, tenantID) creds := auth2.NewClientCredentialsConfig(clientID, clientSecret, tenantID)

View File

@ -14,11 +14,16 @@ func AzureLoginCommand() *cobra.Command {
Short: "Log in to azure", Short: "Log in to azure",
Args: cobra.MaximumNArgs(0), Args: cobra.MaximumNArgs(0),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if err := opts.Validate(); err != nil {
return err
}
return cloudLogin(cmd, "aci", opts) return cloudLogin(cmd, "aci", opts)
}, },
} }
flags := cmd.Flags() flags := cmd.Flags()
flags.StringVar(&opts.TenantID, "tenant-id", "", "Specify tenant ID to use from your azure account") flags.StringVar(&opts.TenantID, "tenant-id", "", "Specify tenant ID to use")
flags.StringVar(&opts.ClientID, "client-id", "", "Client ID for Service principal login")
flags.StringVar(&opts.ClientSecret, "client-secret", "", "Client secret for Service principal login")
return cmd return cmd
} }

View File

@ -76,7 +76,7 @@ func TestLoginLogout(t *testing.T) {
rg := "E2E-" + startTime rg := "E2E-" + startTime
t.Run("login", func(t *testing.T) { t.Run("login", func(t *testing.T) {
azureLogin(t) azureLogin(t, c)
}) })
t.Run("create context", func(t *testing.T) { t.Run("create context", func(t *testing.T) {
@ -506,7 +506,7 @@ func TestRunEnvVars(t *testing.T) {
func setupTestResourceGroup(t *testing.T, c *E2eCLI, tName string) (string, string) { func setupTestResourceGroup(t *testing.T, c *E2eCLI, tName string) (string, string) {
startTime := strconv.Itoa(int(time.Now().Unix())) startTime := strconv.Itoa(int(time.Now().Unix()))
rg := "E2E-" + tName + "-" + startTime rg := "E2E-" + tName + "-" + startTime
azureLogin(t) azureLogin(t, c)
sID := getSubscriptionID(t) sID := getSubscriptionID(t)
t.Logf("Create resource group %q", rg) t.Logf("Create resource group %q", rg)
err := createResourceGroup(sID, rg) err := createResourceGroup(sID, rg)
@ -537,17 +537,14 @@ func deleteResourceGroup(rgName string) error {
return helper.DeleteAsync(ctx, *models[0].SubscriptionID, rgName) return helper.DeleteAsync(ctx, *models[0].SubscriptionID, rgName)
} }
func azureLogin(t *testing.T) { func azureLogin(t *testing.T, c *E2eCLI) {
t.Log("Log in to Azure") t.Log("Log in to Azure")
login, err := login.NewAzureLoginService()
assert.NilError(t, err)
// in order to create new service principal and get these 3 values : `az ad sp create-for-rbac --name 'TestServicePrincipal' --sdk-auth` // in order to create new service principal and get these 3 values : `az ad sp create-for-rbac --name 'TestServicePrincipal' --sdk-auth`
clientID := os.Getenv("AZURE_CLIENT_ID") clientID := os.Getenv("AZURE_CLIENT_ID")
clientSecret := os.Getenv("AZURE_CLIENT_SECRET") clientSecret := os.Getenv("AZURE_CLIENT_SECRET")
tenantID := os.Getenv("AZURE_TENANT_ID") tenantID := os.Getenv("AZURE_TENANT_ID")
err = login.TestLoginFromServicePrincipal(clientID, clientSecret, tenantID) res := c.RunDockerCmd("login", "azure", "--client-id", clientID, "--client-secret", clientSecret, "--tenant-id", tenantID)
assert.NilError(t, err) res.Assert(t, icmd.Success)
} }
func getSubscriptionID(t *testing.T) string { func getSubscriptionID(t *testing.T) string {