pkg/cloud/azureclient.go (197 lines of code) (raw):
package cloud
import (
"context"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"net/http"
"os"
"regexp"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
armpolicy "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions"
"github.com/Azure/go-autorest/autorest/azure"
kiotaauth "github.com/microsoft/kiota-authentication-azure-go"
msgraphsdk "github.com/microsoftgraph/msgraph-sdk-go"
"github.com/microsoftgraph/msgraph-sdk-go/models"
"github.com/pkg/errors"
"monis.app/mlog"
)
// ref: https://docs.microsoft.com/en-us/graph/migrate-azure-ad-graph-request-differences#basic-requests
var msGraphEndpoint = map[azure.Environment]string{
azure.PublicCloud: "https://graph.microsoft.com/",
azure.USGovernmentCloud: "https://graph.microsoft.us/",
azure.ChinaCloud: "https://microsoftgraph.chinacloudapi.cn/",
azure.GermanCloud: "https://graph.microsoft.de/",
}
type Interface interface {
CreateServicePrincipal(ctx context.Context, appID string, tags []string) (models.ServicePrincipalable, error)
CreateApplication(ctx context.Context, displayName string) (models.Applicationable, error)
DeleteServicePrincipal(ctx context.Context, objectID string) error
DeleteApplication(ctx context.Context, objectID string) error
GetServicePrincipal(ctx context.Context, displayName string) (models.ServicePrincipalable, error)
GetApplication(ctx context.Context, displayName string) (models.Applicationable, error)
// Role assignment methods
CreateRoleAssignment(ctx context.Context, scope, roleName, principalID string) (armauthorization.RoleAssignment, error)
DeleteRoleAssignment(ctx context.Context, roleAssignmentID string) (armauthorization.RoleAssignment, error)
// Role definition methods
GetRoleDefinitionIDByName(ctx context.Context, scope, roleName string) (armauthorization.RoleDefinition, error)
// Federation methods
AddFederatedCredential(ctx context.Context, objectID string, fic models.FederatedIdentityCredentialable) error
GetFederatedCredential(ctx context.Context, objectID, issuer, subject string) (models.FederatedIdentityCredentialable, error)
DeleteFederatedCredential(ctx context.Context, objectID, federatedCredentialID string) error
}
type AzureClient struct {
environment azure.Environment
subscriptionID string
graphServiceClient *msgraphsdk.GraphServiceClient
roleAssignmentsClient *armauthorization.RoleAssignmentsClient
roleDefinitionsClient *armauthorization.RoleDefinitionsClient
}
// NewAzureClientWithCLI creates an AzureClient configured from Azure CLI 2.0 for local development scenarios.
func NewAzureClientWithCLI(env azure.Environment, subscriptionID string, client *http.Client) (*AzureClient, error) {
cred, err := azidentity.NewAzureCLICredential(nil)
if err != nil {
return nil, errors.Wrap(err, "failed to create credential")
}
return getClient(env, subscriptionID, cred, client)
}
// NewAzureClientWithClientSecret returns an AzureClient via client_id and client_secret
func NewAzureClientWithClientSecret(env azure.Environment, subscriptionID, clientID, clientSecret, tenantID string, client *http.Client) (*AzureClient, error) {
cred, err := azidentity.NewClientSecretCredential(tenantID, clientID, clientSecret,
&azidentity.ClientSecretCredentialOptions{
ClientOptions: azcore.ClientOptions{
Transport: client,
},
})
if err != nil {
return nil, errors.Wrap(err, "failed to create credential")
}
return getClient(env, subscriptionID, cred, client)
}
// NewAzureClientWithClientCertificateFile returns an AzureClient via client_id and jwt certificate assertion
func NewAzureClientWithClientCertificateFile(env azure.Environment, subscriptionID, clientID, tenantID, certificatePath, privateKeyPath string, client *http.Client) (*AzureClient, error) {
certificateData, err := os.ReadFile(certificatePath)
if err != nil {
return nil, errors.Wrap(err, "Failed to read certificate")
}
block, _ := pem.Decode(certificateData)
if block == nil {
return nil, errors.New("Failed to decode pem block from certificate")
}
certificate, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, errors.Wrap(err, "Failed to parse certificate")
}
privateKey, err := parseRsaPrivateKey(privateKeyPath)
if err != nil {
return nil, errors.Wrap(err, "Failed to parse rsa private key")
}
return NewAzureClientWithClientCertificate(env, subscriptionID, clientID, tenantID, certificate, privateKey, client)
}
// NewAzureClientWithClientCertificate returns an AzureClient via client_id and jwt certificate assertion
func NewAzureClientWithClientCertificate(env azure.Environment, subscriptionID, clientID, tenantID string, certificate *x509.Certificate, privateKey *rsa.PrivateKey, client *http.Client) (*AzureClient, error) {
return newAzureClientWithCertificate(env, subscriptionID, clientID, tenantID, certificate, privateKey, client)
}
func newAzureClientWithCertificate(env azure.Environment, subscriptionID, clientID, tenantID string, certificate *x509.Certificate, privateKey *rsa.PrivateKey, client *http.Client) (*AzureClient, error) {
if certificate == nil {
return nil, errors.New("certificate should not be nil")
}
if privateKey == nil {
return nil, errors.New("privateKey should not be nil")
}
cred, err := azidentity.NewClientCertificateCredential(tenantID, clientID, []*x509.Certificate{certificate}, privateKey,
&azidentity.ClientCertificateCredentialOptions{
ClientOptions: azcore.ClientOptions{
Transport: client,
},
})
if err != nil {
return nil, errors.Wrap(err, "failed to create credential")
}
return getClient(env, subscriptionID, cred, client)
}
func getClient(env azure.Environment, subscriptionID string, credential azcore.TokenCredential, client *http.Client) (*AzureClient, error) {
auth, err := kiotaauth.NewAzureIdentityAuthenticationProviderWithScopes(credential, []string{getGraphScope(env)})
if err != nil {
return nil, errors.Wrap(err, "failed to create authentication provider")
}
adapter, err := msgraphsdk.NewGraphRequestAdapterWithParseNodeFactoryAndSerializationWriterFactoryAndHttpClient(auth, nil, nil, client)
if err != nil {
return nil, errors.Wrap(err, "failed to create request adapter")
}
roleAssignmentsClient, err := armauthorization.NewRoleAssignmentsClient(subscriptionID, credential, nil)
if err != nil {
return nil, errors.Wrap(err, "failed to create role assignments client")
}
roleDefinitionsClient, err := armauthorization.NewRoleDefinitionsClient(credential, nil)
if err != nil {
return nil, errors.Wrap(err, "failed to create role definitions client")
}
azClient := &AzureClient{
environment: env,
subscriptionID: subscriptionID,
graphServiceClient: msgraphsdk.NewGraphServiceClient(adapter),
roleAssignmentsClient: roleAssignmentsClient,
roleDefinitionsClient: roleDefinitionsClient,
}
return azClient, nil
}
var _ azcore.TokenCredential = (*dummyCredential)(nil)
// dummyCredential is a dummy implementation of azcore.TokenCredential to be used
// when we only need to get the tenantID from a subscriptionID
type dummyCredential struct{}
func (d *dummyCredential) GetToken(_ context.Context, _ policy.TokenRequestOptions) (azcore.AccessToken, error) {
return azcore.AccessToken{}, nil
}
// GetTenantID returns the tenantID for the given subscriptionID
// The tenantID is parsed from the WWW-Authenticate header of a failed request
func GetTenantID(subscriptionID string, client *http.Client) (string, error) {
const hdrKey = "WWW-Authenticate"
clientOpts := &armpolicy.ClientOptions{
ClientOptions: azcore.ClientOptions{
Transport: client,
},
}
subscriptionsClient, err := armsubscriptions.NewClient(&dummyCredential{}, clientOpts)
if err != nil {
return "", errors.Wrap(err, "failed to create subscriptions client")
}
mlog.Debug("Resolving tenantID", "subscriptionID", subscriptionID)
// we expect this request to fail (err != nil), but we are only interested
// in headers, so surface the error if the Response is not present (i.e.
// network error etc)
ctx, cancel := context.WithTimeout(context.Background(), time.Minute*150)
defer cancel()
_, err = subscriptionsClient.Get(ctx, subscriptionID, &armsubscriptions.ClientGetOptions{})
var respErr *azcore.ResponseError
if !errors.As(err, &respErr) {
return "", errors.Errorf("unexpected response from get subscription: %v", err)
}
hdr := respErr.RawResponse.Header.Get(hdrKey)
if hdr == "" {
return "", errors.Errorf("header %q not found in get subscription response", hdrKey)
}
// Example value for hdr:
// Bearer authorization_uri="https://login.windows.net/996fe9d1-6171-40aa-945b-4c64b63bf655", error="invalid_token", error_description="The authentication failed because of missing 'Authorization' header."
r := regexp.MustCompile(`authorization_uri=".*/([0-9a-f\-]+)"`)
m := r.FindStringSubmatch(hdr)
if m == nil {
return "", errors.Errorf("Could not find the tenant ID in header: %s %q", hdrKey, hdr)
}
return m[1], nil
}
func parseRsaPrivateKey(path string) (*rsa.PrivateKey, error) {
privateKeyData, err := os.ReadFile(path)
if err != nil {
return nil, err
}
block, _ := pem.Decode(privateKeyData)
if block == nil {
return nil, errors.New("Failed to decode a pem block from private key")
}
privatePkcs1Key, errPkcs1 := x509.ParsePKCS1PrivateKey(block.Bytes)
if errPkcs1 == nil {
return privatePkcs1Key, nil
}
privatePkcs8Key, errPkcs8 := x509.ParsePKCS8PrivateKey(block.Bytes)
if errPkcs8 == nil {
privatePkcs8RsaKey, ok := privatePkcs8Key.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("pkcs8 contained non-RSA key. Expected RSA key")
}
return privatePkcs8RsaKey, nil
}
return nil, errors.Errorf("failed to parse private key as Pkcs#1 or Pkcs#8. (%s). (%s)", errPkcs1, errPkcs8)
}
func getGraphScope(env azure.Environment) string {
return fmt.Sprintf("%s.default", msGraphEndpoint[env])
}