diff --git a/doc/plugin_server_bundlepublisher_azure_blob.md b/doc/plugin_server_bundlepublisher_azure_blob.md new file mode 100644 index 0000000000..c2446eccff --- /dev/null +++ b/doc/plugin_server_bundlepublisher_azure_blob.md @@ -0,0 +1,104 @@ +# Server plugin: BundlePublisher "azure_blob" + +The `azure_blob` plugin puts the current trust bundle of the server in a designated +Azure Blob Storage container, keeping it updated. + +The plugin accepts the following configuration options: + +| Configuration | Description | Required | Default | +|----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------|-----------------------| +| storage_account_name | The name of the Azure Storage account. | Yes. | | +| storage_account_key | The base64-encoded access key for the Azure Storage account. Used for shared key authentication (found in the Azure Portal, **Access keys**). | Required only when using shared key authentication. | | +| container_name | The name of the blob container to which the trust bundle is uploaded. | Yes. | | +| blob_name | The blob name inside the container. | Yes. | | +| format | Format in which the trust bundle is stored, <spiffe | jwks | pem>. See [Supported bundle formats](#supported-bundle-formats) for more details. | Yes. | | +| service_endpoint | The Azure Blob Storage service endpoint. | No. | blob.core.windows.net | +| tenant_id | The Azure tenant ID for client secret credential authentication. | Required only when using client secret credentials. | | +| app_id | The Azure application (client) ID for client secret credential authentication. | Required only when using client secret credentials. | | +| app_secret | The Azure application client secret for client secret credential authentication. | Required only when using client secret credentials. | | +| refresh_hint | Sets the refresh hint for the bundle when using the spiffe format. Specified as string e.g. '10m', '1h'. See [time.ParseDuration](https://pkg.go.dev/time#ParseDuration) for details | No. | | + +## Supported bundle formats + +The following bundle formats are supported: + +### SPIFFE format + +The trust bundle is represented as an RFC 7517 compliant JWK Set, with the specific parameters defined in the [SPIFFE Trust Domain and Bundle specification](https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE_Trust_Domain_and_Bundle.md#4-spiffe-bundle-format). Both the JWT authorities and the X.509 authorities are included. + +### JWKS format + +The trust bundle is encoded as an RFC 7517 compliant JWK Set, omitting SPIFFE-specific parameters. Both the JWT authorities and the X.509 authorities are included. + +### PEM format + +The trust bundle is formatted using PEM encoding. Only the X.509 authorities are included. + +## Authentication + +The plugin supports three authentication methods. Only one method may be used at a time; shared key authentication and client secret credentials are mutually exclusive. + +### Shared key authentication + +When `storage_account_key` is provided, the plugin authenticates using the storage account's access key. This method does not require Azure AD and is useful in environments where Azure AD is not available. + +### Client secret credentials + +When `tenant_id`, `app_id`, and `app_secret` are all provided, the plugin authenticates using Azure client secret credentials. All three fields must be specified together. + +### Default Azure credentials + +When neither shared key nor client secret credentials are configured, the plugin uses the [Azure Default Credential](https://learn.microsoft.com/en-us/azure/developer/go/azure-sdk-authentication) chain. This supports Managed Identity, environment variables, Azure CLI credentials, and other methods provided by the Azure SDK. + +## Required permissions + +The authenticated identity must have the `Storage Blob Data Contributor` role (or equivalent permissions to write blobs) on the configured storage account or container. + +## Sample configuration using Default Azure Credentials + +The following configuration uploads the local trust bundle contents to the `example.org` blob in the `spire-bundle` container within the `mystorageaccount` storage account. Since client secret credentials are not configured, [Default Azure Credentials](https://learn.microsoft.com/en-us/azure/developer/go/azure-sdk-authentication) are used. + +```hcl + BundlePublisher "azure_blob" { + plugin_data { + storage_account_name = "mystorageaccount" + container_name = "spire-bundle" + blob_name = "example.org" + format = "spiffe" + } + } +``` + +## Sample configuration using shared key authentication + +The following configuration uploads the local trust bundle contents to the `example.org` blob in the `spire-bundle` container, authenticating with a storage account access key. + +```hcl + BundlePublisher "azure_blob" { + plugin_data { + storage_account_name = "mystorageaccount" + storage_account_key = "my-storage-account-key" + container_name = "spire-bundle" + blob_name = "example.org" + format = "spiffe" + } + } +``` + +## Sample configuration using client secret credentials + +The following configuration uploads the local trust bundle contents to the `example.org` blob in the `spire-bundle` container, authenticating with client secret credentials. + +```hcl + BundlePublisher "azure_blob" { + plugin_data { + storage_account_name = "mystorageaccount" + container_name = "spire-bundle" + blob_name = "example.org" + format = "spiffe" + tenant_id = "my-tenant-id" + app_id = "my-app-id" + app_secret = "my-app-secret" + } + } +``` diff --git a/doc/spire_server.md b/doc/spire_server.md index 11edf4de4a..24e3c3a394 100644 --- a/doc/spire_server.md +++ b/doc/spire_server.md @@ -45,6 +45,7 @@ This document is a configuration reference for SPIRE Server. It includes informa | BundlePublisher | [aws_s3](/doc/plugin_server_bundlepublisher_aws_s3.md) | Publishes the trust bundle to an Amazon S3 bucket. | | BundlePublisher | [gcp_cloudstorage](/doc/plugin_server_bundlepublisher_gcp_cloudstorage.md) | Publishes the trust bundle to a Google Cloud Storage bucket. | | BundlePublisher | [aws_rolesanywhere_trustanchor](/doc/plugin_server_bundlepublisher_aws_rolesanywhere_trustanchor.md) | Publishes the trust bundle to an AWS IAM Roles Anywhere trust anchor. | +| BundlePublisher | [azure_blob](/doc/plugin_server_bundlepublisher_azure_blob.md) | Publishes the trust bundle to an Azure Blob Storage account. | | BundlePublisher | [k8s_configmap](/doc/plugin_server_bundlepublisher_k8s_configmap.md) | Publishes the trust bundle to a Kubernetes ConfigMap. | ## Server configuration file diff --git a/go.mod b/go.mod index 15e9c03299..a830d43d76 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.10.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v3 v3.0.1 github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 + github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.7.0 github.com/GoogleCloudPlatform/cloudsql-proxy v1.38.1 github.com/Keyfactor/ejbca-go-client-sdk v1.1.0 github.com/Masterminds/sprig/v3 v3.3.0 diff --git a/go.sum b/go.sum index e44096733e..4f6ff4aacf 100644 --- a/go.sum +++ b/go.sum @@ -57,10 +57,14 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1. github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v3 v3.0.1 h1:guyQA4b8XB2sbJZXzUnOF9mn0WDBv/ZT7me9wTipKtE= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources/v3 v3.0.1/go.mod h1:8h8yhzh9o+0HeSIhUxYny+rEQajScrfIpNktvgYG3Q8= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0 h1:MaKvxE6D0KkjOg6Wd9M00iqP5PR0kUxCfiezes4JweM= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.5.0/go.mod h1:i2h9fsTFKZorh8RdV2IcSUf/Qj98GlTkrTvUbX/s8as= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.7.0 h1:BM85pSYlVYQHdq00nxyPoOkyLF5NArJG3bOsrmbwr4k= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.7.0/go.mod h1:QYjP2cB7ZYtS/8jAbE0VSBZde/tjExqGjp+8JY6/+ts= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= diff --git a/pkg/server/catalog/bundlepublisher.go b/pkg/server/catalog/bundlepublisher.go index e98c8164c8..d37de35977 100644 --- a/pkg/server/catalog/bundlepublisher.go +++ b/pkg/server/catalog/bundlepublisher.go @@ -5,6 +5,7 @@ import ( "github.com/spiffe/spire/pkg/server/plugin/bundlepublisher" "github.com/spiffe/spire/pkg/server/plugin/bundlepublisher/awsrolesanywhere" "github.com/spiffe/spire/pkg/server/plugin/bundlepublisher/awss3" + "github.com/spiffe/spire/pkg/server/plugin/bundlepublisher/azureblob" "github.com/spiffe/spire/pkg/server/plugin/bundlepublisher/gcpcloudstorage" "github.com/spiffe/spire/pkg/server/plugin/bundlepublisher/k8sconfigmap" ) @@ -28,6 +29,7 @@ func (repo *bundlePublisherRepository) Versions() []catalog.Version { func (repo *bundlePublisherRepository) BuiltIns() []catalog.BuiltIn { return []catalog.BuiltIn{ awss3.BuiltIn(), + azureblob.BuiltIn(), gcpcloudstorage.BuiltIn(), awsrolesanywhere.BuiltIn(), k8sconfigmap.BuiltIn(), diff --git a/pkg/server/plugin/bundlepublisher/azureblob/azureblob.go b/pkg/server/plugin/bundlepublisher/azureblob/azureblob.go new file mode 100644 index 0000000000..bdda1fad7d --- /dev/null +++ b/pkg/server/plugin/bundlepublisher/azureblob/azureblob.go @@ -0,0 +1,313 @@ +package azureblob + +import ( + "context" + "fmt" + "net/url" + "sync" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/hcl" + "github.com/spiffe/spire-plugin-sdk/pluginsdk/support/bundleformat" + bundlepublisherv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/bundlepublisher/v1" + "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/types" + configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1" + "github.com/spiffe/spire/pkg/common/catalog" + "github.com/spiffe/spire/pkg/common/pluginconf" + "github.com/spiffe/spire/pkg/server/plugin/bundlepublisher/common" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +const ( + pluginName = "azure_blob" + defaultEndpoint = "blob.core.windows.net" +) + +type pluginHooks struct { + newBlobClientFunc func(cred azcore.TokenCredential, accountURL string) (blobStorage, error) + newBlobClientSharedKeyFunc func(accountURL string, cred *azblob.SharedKeyCredential) (blobStorage, error) + fetchCredential func() (azcore.TokenCredential, error) +} + +func BuiltIn() catalog.BuiltIn { + return builtin(New()) +} + +func New() *Plugin { + return newPlugin(newAzureBlobClient) +} + +// Config holds the configuration of the plugin. +type Config struct { + StorageAccountName string `hcl:"storage_account_name" json:"storage_account_name"` + StorageAccountKey string `hcl:"storage_account_key" json:"storage_account_key"` + ServiceEndpoint string `hcl:"service_endpoint" json:"service_endpoint"` + ContainerName string `hcl:"container_name" json:"container_name"` + BlobName string `hcl:"blob_name" json:"blob_name"` + Format string `hcl:"format" json:"format"` + TenantID string `hcl:"tenant_id" json:"tenant_id"` + AppID string `hcl:"app_id" json:"app_id"` + AppSecret string `hcl:"app_secret" json:"app_secret"` + RefreshHint string `hcl:"refresh_hint" json:"refresh_hint"` + + bundleFormat bundleformat.Format + parsedRefreshHint int64 + accountURL string +} + +func buildConfig(coreConfig catalog.CoreConfig, hclText string, status *pluginconf.Status) *Config { + newConfig := new(Config) + + if err := hcl.Decode(newConfig, hclText); err != nil { + status.ReportErrorf("unable to decode configuration: %v", err) + return nil + } + + if newConfig.StorageAccountName == "" { + status.ReportError("configuration is missing the storage account name") + } + if newConfig.ContainerName == "" { + status.ReportError("configuration is missing the container name") + } + if newConfig.BlobName == "" { + status.ReportError("configuration is missing the blob name") + } + if newConfig.Format == "" { + status.ReportError("configuration is missing the bundle format") + } + + bundleFormat, err := bundleformat.FromString(newConfig.Format) + if err != nil { + status.ReportErrorf("could not parse bundle format from configuration: %v", err) + } else { + switch bundleFormat { + case bundleformat.JWKS: + case bundleformat.SPIFFE: + case bundleformat.PEM: + default: + status.ReportErrorf("bundle format %q is not supported", newConfig.Format) + } + newConfig.bundleFormat = bundleFormat + } + + serviceEndpoint := newConfig.ServiceEndpoint + if serviceEndpoint == "" { + serviceEndpoint = defaultEndpoint + } + newConfig.accountURL = fmt.Sprintf("https://%s.%s", newConfig.StorageAccountName, serviceEndpoint) + if _, err := url.ParseRequestURI(newConfig.accountURL); err != nil { + status.ReportErrorf("could not parse service endpoint url: %v", err) + } + + if newConfig.RefreshHint != "" { + refreshHint, err := common.ParseRefreshHint(newConfig.RefreshHint, status) + if err != nil { + status.ReportErrorf("could not parse refresh_hint: %v", err) + } + newConfig.parsedRefreshHint = refreshHint + } + + switch { + case newConfig.StorageAccountKey != "": + if newConfig.TenantID != "" || newConfig.AppID != "" || newConfig.AppSecret != "" { + status.ReportError("storage account key and client secret credentials are mutually exclusive") + } + case newConfig.TenantID != "" || newConfig.AppID != "" || newConfig.AppSecret != "": + if newConfig.TenantID == "" { + status.ReportError("configuration is missing the tenant ID") + } + if newConfig.AppID == "" { + status.ReportError("configuration is missing the app ID") + } + if newConfig.AppSecret == "" { + status.ReportError("configuration is missing the app secret") + } + } + + return newConfig +} + +// Plugin is the main representation of this bundle publisher plugin. +type Plugin struct { + bundlepublisherv1.UnsafeBundlePublisherServer + configv1.UnsafeConfigServer + + config *Config + blobClient blobStorage + configMtx sync.RWMutex + + bundle *types.Bundle + bundleMtx sync.RWMutex + + hooks pluginHooks + log hclog.Logger +} + +// SetLogger sets a logger in the plugin. +func (p *Plugin) SetLogger(log hclog.Logger) { + p.log = log +} + +// Configure configures the plugin. +func (p *Plugin) Configure(_ context.Context, req *configv1.ConfigureRequest) (*configv1.ConfigureResponse, error) { + newConfig, notes, err := pluginconf.Build(req, buildConfig) + if err != nil { + return nil, err + } + for _, note := range notes { + p.log.Warn(note) + } + + var blobClient blobStorage + + switch { + case newConfig.StorageAccountKey != "": + sharedKeyCred, sharedKeyErr := azblob.NewSharedKeyCredential(newConfig.StorageAccountName, newConfig.StorageAccountKey) + if sharedKeyErr != nil { + return nil, status.Errorf(codes.Internal, "unable to get shared key credential: %v", sharedKeyErr) + } + + blobClient, err = p.hooks.newBlobClientSharedKeyFunc(newConfig.accountURL, sharedKeyCred) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to create client: %v", err) + } + + case newConfig.TenantID != "" || newConfig.AppID != "" || newConfig.AppSecret != "": + var cred azcore.TokenCredential + cred, err = azidentity.NewClientSecretCredential(newConfig.TenantID, newConfig.AppID, newConfig.AppSecret, nil) + if err != nil { + return nil, status.Errorf(codes.Internal, "unable to get client credential: %v", err) + } + + blobClient, err = p.hooks.newBlobClientFunc(cred, newConfig.accountURL) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to create client: %v", err) + } + + default: + var cred azcore.TokenCredential + cred, err = p.hooks.fetchCredential() + if err != nil { + return nil, status.Errorf(codes.Internal, "unable to fetch default credential: %v", err) + } + + blobClient, err = p.hooks.newBlobClientFunc(cred, newConfig.accountURL) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to create client: %v", err) + } + } + // Store the config and client together under one lock so that + // PublishBundle always observes a matching pair, even when Configure + // runs concurrently as a result of a dynamic reconfiguration. + p.setConfig(newConfig, blobClient) + p.setBundle(nil) + return &configv1.ConfigureResponse{}, nil +} + +func (p *Plugin) Validate(_ context.Context, req *configv1.ValidateRequest) (*configv1.ValidateResponse, error) { + _, notes, err := pluginconf.Build(req, buildConfig) + + return &configv1.ValidateResponse{ + Valid: err == nil, + Notes: notes, + }, nil +} + +// PublishBundle puts the bundle in the configured Azure Blob Storage container. +func (p *Plugin) PublishBundle(ctx context.Context, req *bundlepublisherv1.PublishBundleRequest) (*bundlepublisherv1.PublishBundleResponse, error) { + config, blobClient, err := p.getConfig() + if err != nil { + return nil, err + } + + if req.Bundle == nil { + return nil, status.Error(codes.InvalidArgument, "missing bundle in request") + } + + currentBundle := p.getBundle() + if proto.Equal(req.Bundle, currentBundle) { + return &bundlepublisherv1.PublishBundleResponse{}, nil + } + + bundleToPublish := proto.Clone(req.Bundle).(*types.Bundle) + if config.parsedRefreshHint != 0 { + bundleToPublish.RefreshHint = config.parsedRefreshHint + } + + formatter := bundleformat.NewFormatter(bundleToPublish) + bundleBytes, err := formatter.Format(config.bundleFormat) + if err != nil { + return nil, status.Errorf(codes.Internal, "could not format bundle: %v", err.Error()) + } + + _, err = blobClient.UploadBuffer(ctx, config.ContainerName, config.BlobName, bundleBytes, nil) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to upload blob: %v", err) + } + + p.setBundle(req.Bundle) + p.log.Debug("Bundle published") + return &bundlepublisherv1.PublishBundleResponse{}, nil +} + +// getBundle gets the latest bundle that the plugin has. +func (p *Plugin) getBundle() *types.Bundle { + p.bundleMtx.RLock() + defer p.bundleMtx.RUnlock() + + return p.bundle +} + +// getConfig gets the configuration and blob client of the plugin. +func (p *Plugin) getConfig() (*Config, blobStorage, error) { + p.configMtx.RLock() + defer p.configMtx.RUnlock() + + if p.config == nil { + return nil, nil, status.Error(codes.FailedPrecondition, "not configured") + } + return p.config, p.blobClient, nil +} + +// setBundle updates the current bundle in the plugin with the provided bundle. +func (p *Plugin) setBundle(bundle *types.Bundle) { + p.bundleMtx.Lock() + defer p.bundleMtx.Unlock() + + p.bundle = bundle +} + +// setConfig sets the configuration and blob client for the plugin. +func (p *Plugin) setConfig(config *Config, blobClient blobStorage) { + p.configMtx.Lock() + defer p.configMtx.Unlock() + + p.config = config + p.blobClient = blobClient +} + +// builtin creates a new BundlePublisher built-in plugin. +func builtin(p *Plugin) catalog.BuiltIn { + return catalog.MakeBuiltIn(pluginName, + bundlepublisherv1.BundlePublisherPluginServer(p), + configv1.ConfigServiceServer(p), + ) +} + +// newPlugin returns a new plugin instance. +func newPlugin(newBlobClientFunc func(cred azcore.TokenCredential, accountURL string) (blobStorage, error)) *Plugin { + return &Plugin{ + hooks: pluginHooks{ + newBlobClientFunc: newBlobClientFunc, + newBlobClientSharedKeyFunc: newAzureBlobClientWithSharedKey, + fetchCredential: func() (azcore.TokenCredential, error) { + return azidentity.NewDefaultAzureCredential(nil) + }, + }, + } +} diff --git a/pkg/server/plugin/bundlepublisher/azureblob/azureblob_test.go b/pkg/server/plugin/bundlepublisher/azureblob/azureblob_test.go new file mode 100644 index 0000000000..59cedc9343 --- /dev/null +++ b/pkg/server/plugin/bundlepublisher/azureblob/azureblob_test.go @@ -0,0 +1,627 @@ +package azureblob + +import ( + "bytes" + "context" + "crypto/x509" + "errors" + "fmt" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" + "github.com/spiffe/go-spiffe/v2/spiffeid" + "github.com/spiffe/spire-plugin-sdk/pluginsdk/support/bundleformat" + bundlepublisherv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/server/bundlepublisher/v1" + "github.com/spiffe/spire-plugin-sdk/proto/spire/plugin/types" + configv1 "github.com/spiffe/spire-plugin-sdk/proto/spire/service/common/config/v1" + "github.com/spiffe/spire/pkg/common/bundleutil" + "github.com/spiffe/spire/pkg/common/catalog" + "github.com/spiffe/spire/test/plugintest" + "github.com/spiffe/spire/test/spiretest" + "github.com/spiffe/spire/test/util" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" +) + +func TestConfigure(t *testing.T) { + for _, tt := range []struct { + name string + + configureRequest *configv1.ConfigureRequest + newClientErr error + newSharedKeyClientErr error + fetchCredentialErr error + expectCode codes.Code + expectMsg string + config *Config + }{ + { + name: "success with service principal", + config: &Config{ + StorageAccountName: "myaccount", + ContainerName: "my-container", + BlobName: "bundle.json", + Format: "spiffe", + TenantID: "tenant-id", + AppID: "app-id", + AppSecret: "app-secret", + }, + }, + { + name: "success with shared key", + config: &Config{ + StorageAccountName: "myaccount", + StorageAccountKey: "dGVzdC1rZXk=", + ContainerName: "my-container", + BlobName: "bundle.json", + Format: "spiffe", + }, + }, + { + name: "success with default credential", + config: &Config{ + StorageAccountName: "myaccount", + ContainerName: "my-container", + BlobName: "bundle.json", + Format: "spiffe", + }, + }, + { + name: "success with refresh hint", + config: &Config{ + StorageAccountName: "myaccount", + ContainerName: "my-container", + BlobName: "bundle.json", + Format: "spiffe", + RefreshHint: "1h", + }, + }, + { + name: "success with custom service endpoint", + config: &Config{ + StorageAccountName: "myaccount", + ContainerName: "my-container", + BlobName: "bundle.json", + Format: "spiffe", + ServiceEndpoint: "blob.core.usgovcloudapi.net", + }, + }, + { + name: "invalid service endpoint", + config: &Config{ + StorageAccountName: "myaccount", + ContainerName: "my-container", + BlobName: "bundle.json", + Format: "spiffe", + ServiceEndpoint: "invalid host name", + }, + expectCode: codes.InvalidArgument, + expectMsg: "could not parse service endpoint url", + }, + { + name: "no storage account name", + config: &Config{ + ContainerName: "my-container", + BlobName: "bundle.json", + Format: "spiffe", + }, + expectCode: codes.InvalidArgument, + expectMsg: "configuration is missing the storage account name", + }, + { + name: "no container name", + config: &Config{ + StorageAccountName: "myaccount", + BlobName: "bundle.json", + Format: "spiffe", + }, + expectCode: codes.InvalidArgument, + expectMsg: "configuration is missing the container name", + }, + { + name: "no blob name", + config: &Config{ + StorageAccountName: "myaccount", + ContainerName: "my-container", + Format: "spiffe", + }, + expectCode: codes.InvalidArgument, + expectMsg: "configuration is missing the blob name", + }, + { + name: "no bundle format", + config: &Config{ + StorageAccountName: "myaccount", + ContainerName: "my-container", + BlobName: "bundle.json", + }, + expectCode: codes.InvalidArgument, + expectMsg: "configuration is missing the bundle format", + }, + { + name: "shared key with service principal is mutually exclusive", + config: &Config{ + StorageAccountName: "myaccount", + StorageAccountKey: "dGVzdC1rZXk=", + ContainerName: "my-container", + BlobName: "bundle.json", + Format: "spiffe", + TenantID: "tenant-id", + AppID: "app-id", + AppSecret: "app-secret", + }, + expectCode: codes.InvalidArgument, + expectMsg: "storage account key and client secret credentials are mutually exclusive", + }, + { + name: "shared key client error", + config: &Config{ + StorageAccountName: "myaccount", + StorageAccountKey: "dGVzdC1rZXk=", + ContainerName: "my-container", + BlobName: "bundle.json", + Format: "spiffe", + }, + expectCode: codes.Internal, + expectMsg: "failed to create client: shared key client error", + newSharedKeyClientErr: errors.New("shared key client error"), + }, + { + name: "missing tenant id with partial service principal config", + config: &Config{ + StorageAccountName: "myaccount", + ContainerName: "my-container", + BlobName: "bundle.json", + Format: "spiffe", + AppID: "app-id", + AppSecret: "app-secret", + }, + expectCode: codes.InvalidArgument, + expectMsg: "configuration is missing the tenant ID", + }, + { + name: "missing app id with partial service principal config", + config: &Config{ + StorageAccountName: "myaccount", + ContainerName: "my-container", + BlobName: "bundle.json", + Format: "spiffe", + TenantID: "tenant-id", + AppSecret: "app-secret", + }, + expectCode: codes.InvalidArgument, + expectMsg: "configuration is missing the app ID", + }, + { + name: "missing app secret with partial service principal config", + config: &Config{ + StorageAccountName: "myaccount", + ContainerName: "my-container", + BlobName: "bundle.json", + Format: "spiffe", + TenantID: "tenant-id", + AppID: "app-id", + }, + expectCode: codes.InvalidArgument, + expectMsg: "configuration is missing the app secret", + }, + { + name: "client error", + config: &Config{ + StorageAccountName: "myaccount", + ContainerName: "my-container", + BlobName: "bundle.json", + Format: "spiffe", + }, + expectCode: codes.Internal, + expectMsg: "failed to create client: client creation error", + newClientErr: errors.New("client creation error"), + }, + { + name: "fetch credential error", + config: &Config{ + StorageAccountName: "myaccount", + ContainerName: "my-container", + BlobName: "bundle.json", + Format: "spiffe", + }, + expectCode: codes.Internal, + expectMsg: "unable to fetch default credential: credential error", + fetchCredentialErr: errors.New("credential error"), + }, + { + name: "invalid refresh hint", + config: &Config{ + StorageAccountName: "myaccount", + ContainerName: "my-container", + BlobName: "bundle.json", + Format: "spiffe", + RefreshHint: "invalid-refresh-hint", + }, + expectCode: codes.InvalidArgument, + expectMsg: "could not parse refresh_hint: could not parse refresh hint \"invalid-refresh-hint\": time: invalid duration \"invalid-refresh-hint\"", + }, + } { + t.Run(tt.name, func(t *testing.T) { + var err error + options := []plugintest.Option{ + plugintest.CaptureConfigureError(&err), + plugintest.CoreConfig(catalog.CoreConfig{ + TrustDomain: spiffeid.RequireTrustDomainFromString("example.org"), + }), + plugintest.ConfigureJSON(tt.config), + } + + newClient := func(cred azcore.TokenCredential, accountURL string) (blobStorage, error) { + if tt.newClientErr != nil { + return nil, tt.newClientErr + } + return &fakeClient{}, nil + } + p := newPlugin(newClient) + p.hooks.newBlobClientSharedKeyFunc = func(accountURL string, cred *azblob.SharedKeyCredential) (blobStorage, error) { + if tt.newSharedKeyClientErr != nil { + return nil, tt.newSharedKeyClientErr + } + return &fakeClient{}, nil + } + p.hooks.fetchCredential = func() (azcore.TokenCredential, error) { + if tt.fetchCredentialErr != nil { + return nil, tt.fetchCredentialErr + } + return &fakeCredential{}, nil + } + + plugintest.Load(t, builtin(p), nil, options...) + spiretest.RequireGRPCStatusHasPrefix(t, err, tt.expectCode, tt.expectMsg) + + if tt.expectMsg != "" { + require.Nil(t, p.config) + return + } + + tt.config.bundleFormat, err = bundleformat.FromString(tt.config.Format) + require.NoError(t, err) + + if tt.config.RefreshHint != "" { + refreshDuration, err := time.ParseDuration(tt.config.RefreshHint) + if err == nil { + tt.config.parsedRefreshHint = int64(refreshDuration.Seconds()) + } + } + + serviceEndpoint := tt.config.ServiceEndpoint + if serviceEndpoint == "" { + serviceEndpoint = "blob.core.windows.net" + } + tt.config.accountURL = fmt.Sprintf("https://%s.%s", tt.config.StorageAccountName, serviceEndpoint) + + require.Equal(t, tt.config, p.config) + }) + } +} + +func TestPublishBundle(t *testing.T) { + testBundle := getTestBundle(t) + + for _, tt := range []struct { + name string + + expectCode codes.Code + expectMsg string + config *Config + bundle *types.Bundle + uploadBufferErr error + }{ + { + name: "success", + bundle: testBundle, + config: &Config{ + StorageAccountName: "myaccount", + ContainerName: "my-container", + BlobName: "bundle.json", + Format: "spiffe", + }, + }, + { + name: "upload failure", + bundle: testBundle, + config: &Config{ + StorageAccountName: "myaccount", + ContainerName: "my-container", + BlobName: "bundle.json", + Format: "spiffe", + }, + uploadBufferErr: errors.New("some error"), + expectCode: codes.Internal, + expectMsg: "failed to upload blob: some error", + }, + { + name: "not configured", + expectCode: codes.FailedPrecondition, + expectMsg: "not configured", + }, + { + name: "missing bundle", + config: &Config{ + StorageAccountName: "myaccount", + ContainerName: "my-container", + BlobName: "bundle.json", + Format: "spiffe", + }, + expectCode: codes.InvalidArgument, + expectMsg: "missing bundle in request", + }, + } { + t.Run(tt.name, func(t *testing.T) { + var err error + options := []plugintest.Option{ + plugintest.CaptureConfigureError(&err), + plugintest.CoreConfig(catalog.CoreConfig{ + TrustDomain: spiffeid.RequireTrustDomainFromString("example.org"), + }), + plugintest.ConfigureJSON(tt.config), + } + + newClient := func(cred azcore.TokenCredential, accountURL string) (blobStorage, error) { + return &fakeClient{ + t: t, + expectContainerName: tt.config.ContainerName, + expectBlobName: tt.config.BlobName, + uploadBufferErr: tt.uploadBufferErr, + }, nil + } + p := newPlugin(newClient) + p.hooks.fetchCredential = func() (azcore.TokenCredential, error) { + return &fakeCredential{}, nil + } + + if tt.config != nil { + plugintest.Load(t, builtin(p), nil, options...) + require.NoError(t, err) + } + + resp, err := p.PublishBundle(context.Background(), &bundlepublisherv1.PublishBundleRequest{ + Bundle: tt.bundle, + }) + + if tt.expectMsg != "" { + spiretest.RequireGRPCStatusContains(t, err, tt.expectCode, tt.expectMsg) + return + } + require.NoError(t, err) + require.NotNil(t, resp) + }) + } +} + +func TestPublishMultiple(t *testing.T) { + config := &Config{ + StorageAccountName: "myaccount", + ContainerName: "my-container", + BlobName: "bundle.json", + Format: "spiffe", + } + + var err error + options := []plugintest.Option{ + plugintest.CaptureConfigureError(&err), + plugintest.CoreConfig(catalog.CoreConfig{ + TrustDomain: spiffeid.RequireTrustDomainFromString("example.org"), + }), + plugintest.ConfigureJSON(config), + } + + newClient := func(cred azcore.TokenCredential, accountURL string) (blobStorage, error) { + return &fakeClient{ + t: t, + expectContainerName: config.ContainerName, + expectBlobName: config.BlobName, + }, nil + } + p := newPlugin(newClient) + p.hooks.fetchCredential = func() (azcore.TokenCredential, error) { + return &fakeCredential{}, nil + } + plugintest.Load(t, builtin(p), nil, options...) + require.NoError(t, err) + + bundle := getTestBundle(t) + bundle.SequenceNumber = 1 + + client, ok := p.blobClient.(*fakeClient) + require.True(t, ok) + + client.uploadBufferCount = 0 + resp, err := p.PublishBundle(context.Background(), &bundlepublisherv1.PublishBundleRequest{ + Bundle: bundle, + }) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, 1, client.uploadBufferCount) + + resp, err = p.PublishBundle(context.Background(), &bundlepublisherv1.PublishBundleRequest{ + Bundle: bundle, + }) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, 1, client.uploadBufferCount) + + bundle = getTestBundle(t) + bundle.SequenceNumber = 2 + resp, err = p.PublishBundle(context.Background(), &bundlepublisherv1.PublishBundleRequest{ + Bundle: bundle, + }) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, 2, client.uploadBufferCount) + + client.uploadBufferErr = errors.New("error calling UploadBuffer") + + resp, err = p.PublishBundle(context.Background(), &bundlepublisherv1.PublishBundleRequest{ + Bundle: bundle, + }) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, 2, client.uploadBufferCount) + + bundle = getTestBundle(t) + bundle.SequenceNumber = 3 + resp, err = p.PublishBundle(context.Background(), &bundlepublisherv1.PublishBundleRequest{ + Bundle: bundle, + }) + require.Error(t, err) + require.Nil(t, resp) + require.Equal(t, 2, client.uploadBufferCount) + + client.uploadBufferErr = nil + resp, err = p.PublishBundle(context.Background(), &bundlepublisherv1.PublishBundleRequest{ + Bundle: bundle, + }) + require.NoError(t, err) + require.NotNil(t, resp) + require.Equal(t, 3, client.uploadBufferCount) +} + +func TestSetRefreshHint(t *testing.T) { + config := &Config{ + StorageAccountName: "myaccount", + ContainerName: "my-container", + BlobName: "bundle.json", + Format: "spiffe", + RefreshHint: "1h", + } + + var err error + options := []plugintest.Option{ + plugintest.CaptureConfigureError(&err), + plugintest.CoreConfig(catalog.CoreConfig{ + TrustDomain: spiffeid.RequireTrustDomainFromString("example.org"), + }), + plugintest.ConfigureJSON(config), + } + + client := &fakeClient{t: t, expectContainerName: config.ContainerName, expectBlobName: config.BlobName} + newClient := func(cred azcore.TokenCredential, accountURL string) (blobStorage, error) { + return client, nil + } + p := newPlugin(newClient) + p.hooks.fetchCredential = func() (azcore.TokenCredential, error) { + return &fakeCredential{}, nil + } + plugintest.Load(t, builtin(p), nil, options...) + require.NoError(t, err) + + bundle := getTestBundle(t) + resp, err := p.PublishBundle(context.Background(), &bundlepublisherv1.PublishBundleRequest{ + Bundle: bundle, + }) + require.NoError(t, err) + require.NotNil(t, resp) + + publishedBundle, err := bundleutil.Decode(spiffeid.RequireTrustDomainFromString("example.org"), bytes.NewReader(client.writtenBytes)) + require.NoError(t, err) + refreshHint, ok := publishedBundle.RefreshHint() + require.True(t, ok) + require.Equal(t, time.Hour, refreshHint) +} + +func TestBundleWithRefreshHintPublishedOnce(t *testing.T) { + config := &Config{ + StorageAccountName: "myaccount", + ContainerName: "my-container", + BlobName: "bundle.json", + Format: "spiffe", + RefreshHint: "1h", + } + + var err error + options := []plugintest.Option{ + plugintest.CaptureConfigureError(&err), + plugintest.CoreConfig(catalog.CoreConfig{ + TrustDomain: spiffeid.RequireTrustDomainFromString("example.org"), + }), + plugintest.ConfigureJSON(config), + } + + client := &fakeClient{t: t, expectContainerName: config.ContainerName, expectBlobName: config.BlobName} + newClient := func(cred azcore.TokenCredential, accountURL string) (blobStorage, error) { + return client, nil + } + p := newPlugin(newClient) + p.hooks.fetchCredential = func() (azcore.TokenCredential, error) { + return &fakeCredential{}, nil + } + plugintest.Load(t, builtin(p), nil, options...) + require.NoError(t, err) + + bundle := getTestBundle(t) + resp, err := p.PublishBundle(context.Background(), &bundlepublisherv1.PublishBundleRequest{ + Bundle: bundle, + }) + require.NoError(t, err) + require.NotNil(t, resp) + + resp, err = p.PublishBundle(context.Background(), &bundlepublisherv1.PublishBundleRequest{ + Bundle: bundle, + }) + require.NoError(t, err) + require.NotNil(t, resp) + + require.Equal(t, 1, client.uploadBufferCount) +} + +type fakeCredential struct{} + +func (f *fakeCredential) GetToken(_ context.Context, _ policy.TokenRequestOptions) (azcore.AccessToken, error) { + return azcore.AccessToken{}, nil +} + +type fakeClient struct { + t *testing.T + + uploadBufferErr error + expectContainerName string + expectBlobName string + uploadBufferCount int + writtenBytes []byte +} + +func (c *fakeClient) UploadBuffer(_ context.Context, containerName string, blobName string, buffer []byte, _ *azblob.UploadBufferOptions) (azblob.UploadBufferResponse, error) { + if c.uploadBufferErr != nil { + return azblob.UploadBufferResponse{}, c.uploadBufferErr + } + + require.Equal(c.t, c.expectContainerName, containerName, "container name mismatch") + require.Equal(c.t, c.expectBlobName, blobName, "blob name mismatch") + + c.writtenBytes = make([]byte, len(buffer)) + copy(c.writtenBytes, buffer) + + c.uploadBufferCount++ + return azblob.UploadBufferResponse{}, nil +} + +func getTestBundle(t *testing.T) *types.Bundle { + cert, _, err := util.LoadCAFixture() + require.NoError(t, err) + + keyPkix, err := x509.MarshalPKIXPublicKey(cert.PublicKey) + require.NoError(t, err) + + return &types.Bundle{ + TrustDomain: "example.org", + X509Authorities: []*types.X509Certificate{{Asn1: cert.Raw}}, + JwtAuthorities: []*types.JWTKey{ + { + KeyId: "KID", + PublicKey: keyPkix, + }, + }, + RefreshHint: 1440, + SequenceNumber: 100, + } +} diff --git a/pkg/server/plugin/bundlepublisher/azureblob/client.go b/pkg/server/plugin/bundlepublisher/azureblob/client.go new file mode 100644 index 0000000000..7cee1ac323 --- /dev/null +++ b/pkg/server/plugin/bundlepublisher/azureblob/client.go @@ -0,0 +1,42 @@ +package azureblob + +import ( + "context" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" +) + +type blobStorage interface { + UploadBuffer(ctx context.Context, containerName string, blobName string, buffer []byte, o *azblob.UploadBufferOptions) (azblob.UploadBufferResponse, error) +} + +type blobClient struct { + client *azblob.Client +} + +func (c *blobClient) UploadBuffer(ctx context.Context, containerName string, blobName string, buffer []byte, o *azblob.UploadBufferOptions) (azblob.UploadBufferResponse, error) { + return c.client.UploadBuffer(ctx, containerName, blobName, buffer, o) +} + +func newAzureBlobClient(cred azcore.TokenCredential, accountURL string) (blobStorage, error) { + client, err := azblob.NewClient(accountURL, cred, nil) + if err != nil { + return nil, err + } + + return &blobClient{ + client: client, + }, nil +} + +func newAzureBlobClientWithSharedKey(accountURL string, cred *azblob.SharedKeyCredential) (blobStorage, error) { + client, err := azblob.NewClientWithSharedKeyCredential(accountURL, cred, nil) + if err != nil { + return nil, err + } + + return &blobClient{ + client: client, + }, nil +}