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]) }