diff --git a/seaweed-volume/proto/remote.proto b/seaweed-volume/proto/remote.proto index 9d6d81ff5..6298c33a0 100644 --- a/seaweed-volume/proto/remote.proto +++ b/seaweed-volume/proto/remote.proto @@ -26,6 +26,7 @@ message RemoteConf { string azure_account_name = 15; string azure_account_key = 16; + string azure_client_id = 17; string backblaze_key_id = 20; string backblaze_application_key = 21; diff --git a/weed/command/scaffold/replication.toml b/weed/command/scaffold/replication.toml index b23a1ef46..40530453e 100644 --- a/weed/command/scaffold/replication.toml +++ b/weed/command/scaffold/replication.toml @@ -59,7 +59,14 @@ is_incremental = false # experimental, let me know if it works enabled = false account_name = "" +# leave account_key empty to authenticate with Entra ID instead of a shared key, +# picking up a workload identity or managed identity from the environment. The +# identity needs a data-plane role such as Storage Blob Data Contributor. account_key = "" +# optional user-assigned identity, when account_key is empty. Workload identity +# takes its tenant and token from AZURE_TENANT_ID and AZURE_FEDERATED_TOKEN_FILE, +# which the Azure workload identity webhook projects into the pod. +client_id = "" container = "mycontainer" # an existing container directory = "/" # destination directory is_incremental = false diff --git a/weed/pb/remote.proto b/weed/pb/remote.proto index f84cd5f8e..2bf7dac59 100644 --- a/weed/pb/remote.proto +++ b/weed/pb/remote.proto @@ -26,6 +26,7 @@ message RemoteConf { string azure_account_name = 15; string azure_account_key = 16; + string azure_client_id = 17; string backblaze_key_id = 20; string backblaze_application_key = 21; diff --git a/weed/pb/remote_pb/remote.pb.go b/weed/pb/remote_pb/remote.pb.go index f5e18060d..ee5580eb2 100644 --- a/weed/pb/remote_pb/remote.pb.go +++ b/weed/pb/remote_pb/remote.pb.go @@ -40,6 +40,7 @@ type RemoteConf struct { GcsProjectId string `protobuf:"bytes,12,opt,name=gcs_project_id,json=gcsProjectId,proto3" json:"gcs_project_id,omitempty"` AzureAccountName string `protobuf:"bytes,15,opt,name=azure_account_name,json=azureAccountName,proto3" json:"azure_account_name,omitempty"` AzureAccountKey string `protobuf:"bytes,16,opt,name=azure_account_key,json=azureAccountKey,proto3" json:"azure_account_key,omitempty"` + AzureClientId string `protobuf:"bytes,17,opt,name=azure_client_id,json=azureClientId,proto3" json:"azure_client_id,omitempty"` BackblazeKeyId string `protobuf:"bytes,20,opt,name=backblaze_key_id,json=backblazeKeyId,proto3" json:"backblaze_key_id,omitempty"` BackblazeApplicationKey string `protobuf:"bytes,21,opt,name=backblaze_application_key,json=backblazeApplicationKey,proto3" json:"backblaze_application_key,omitempty"` BackblazeEndpoint string `protobuf:"bytes,22,opt,name=backblaze_endpoint,json=backblazeEndpoint,proto3" json:"backblaze_endpoint,omitempty"` @@ -201,6 +202,13 @@ func (x *RemoteConf) GetAzureAccountKey() string { return "" } +func (x *RemoteConf) GetAzureClientId() string { + if x != nil { + return x.AzureClientId + } + return "" +} + func (x *RemoteConf) GetBackblazeKeyId() string { if x != nil { return x.BackblazeKeyId @@ -528,7 +536,7 @@ var File_remote_proto protoreflect.FileDescriptor const file_remote_proto_rawDesc = "" + "\n" + - "\fremote.proto\x12\tremote_pb\"\x9b\x0e\n" + + "\fremote.proto\x12\tremote_pb\"\xc3\x0e\n" + "\n" + "RemoteConf\x12\x12\n" + "\x04type\x18\x01 \x01(\tR\x04type\x12\x12\n" + @@ -546,7 +554,8 @@ const file_remote_proto_rawDesc = "" + " \x01(\tR\x1fgcsGoogleApplicationCredentials\x12$\n" + "\x0egcs_project_id\x18\f \x01(\tR\fgcsProjectId\x12,\n" + "\x12azure_account_name\x18\x0f \x01(\tR\x10azureAccountName\x12*\n" + - "\x11azure_account_key\x18\x10 \x01(\tR\x0fazureAccountKey\x12(\n" + + "\x11azure_account_key\x18\x10 \x01(\tR\x0fazureAccountKey\x12&\n" + + "\x0fazure_client_id\x18\x11 \x01(\tR\razureClientId\x12(\n" + "\x10backblaze_key_id\x18\x14 \x01(\tR\x0ebackblazeKeyId\x12:\n" + "\x19backblaze_application_key\x18\x15 \x01(\tR\x17backblazeApplicationKey\x12-\n" + "\x12backblaze_endpoint\x18\x16 \x01(\tR\x11backblazeEndpoint\x12)\n" + diff --git a/weed/remote_storage/azure/azure_credentials.go b/weed/remote_storage/azure/azure_credentials.go new file mode 100644 index 000000000..94516b438 --- /dev/null +++ b/weed/remote_storage/azure/azure_credentials.go @@ -0,0 +1,77 @@ +package azure + +import ( + "fmt" + "os" + "regexp" + + "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/seaweedfs/seaweedfs/weed/glog" +) + +// Azure storage account names are 3 to 24 lowercase letters and digits. The +// name lands in the service URL, where a stray "/", "?" or "@" would move the +// authority somewhere else and send an authenticated request to that host. +var validAzureAccountName = regexp.MustCompile(`^[a-z0-9]{3,24}$`) + +// NewAzBlobClient builds a blob service client for accountName. +// +// An empty accountKey selects Entra ID instead of a shared key: azidentity +// resolves a workload identity, a managed identity, or a developer login from +// the environment, and access is granted through RBAC. Fleets that cannot +// distribute and rotate storage account keys authenticate that way. clientID +// pins a user-assigned identity when the environment offers more than one. +func NewAzBlobClient(accountName, accountKey, clientID string) (*azblob.Client, error) { + + if accountName == "" { + return nil, fmt.Errorf("azure account name is required") + } + if !validAzureAccountName.MatchString(accountName) { + return nil, fmt.Errorf("invalid azure account name %q: expecting 3 to 24 lowercase letters and digits", accountName) + } + + serviceURL := fmt.Sprintf("https://%s.blob.core.windows.net/", accountName) + + if accountKey == "" { + credential, err := newAzureTokenCredential(clientID) + if err != nil { + return nil, fmt.Errorf("failed to create Azure Entra ID credential for account %s: %w", accountName, err) + } + glog.V(1).Infof("azure %s: authenticating with Entra ID", accountName) + client, err := azblob.NewClient(serviceURL, credential, DefaultAzBlobClientOptions()) + if err != nil { + return nil, fmt.Errorf("failed to create Azure client: %w", err) + } + return client, nil + } + + credential, err := azblob.NewSharedKeyCredential(accountName, accountKey) + if err != nil { + return nil, fmt.Errorf("failed to create Azure credential with account name:%s: %w", accountName, err) + } + client, err := azblob.NewClientWithSharedKeyCredential(serviceURL, credential, DefaultAzBlobClientOptions()) + if err != nil { + return nil, fmt.Errorf("failed to create Azure client: %w", err) + } + return client, nil +} + +// newAzureTokenCredential resolves an Entra ID credential. Without a pinned +// clientID the default chain discovers whatever the host offers. With one, the +// federated token file projected by the Azure workload identity webhook tells +// the two identity flavors apart. +func newAzureTokenCredential(clientID string) (azcore.TokenCredential, error) { + if clientID == "" { + return azidentity.NewDefaultAzureCredential(nil) + } + if os.Getenv("AZURE_FEDERATED_TOKEN_FILE") != "" { + return azidentity.NewWorkloadIdentityCredential(&azidentity.WorkloadIdentityCredentialOptions{ + ClientID: clientID, + }) + } + return azidentity.NewManagedIdentityCredential(&azidentity.ManagedIdentityCredentialOptions{ + ID: azidentity.ClientID(clientID), + }) +} diff --git a/weed/remote_storage/azure/azure_credentials_test.go b/weed/remote_storage/azure/azure_credentials_test.go new file mode 100644 index 000000000..0d9c7a4d3 --- /dev/null +++ b/weed/remote_storage/azure/azure_credentials_test.go @@ -0,0 +1,73 @@ +package azure + +import ( + "path/filepath" + "testing" +) + +func TestNewAzBlobClientRequiresAccountName(t *testing.T) { + if _, err := NewAzBlobClient("", "aW52YWxpZGtleQ==", ""); err == nil { + t.Error("expected an error without an account name") + } +} + +func TestNewAzBlobClientRejectsMalformedAccountName(t *testing.T) { + for _, accountName := range []string{"ab", "TestAccount", "test-account", "evil.com/x", "evil@host.com", "account?x=1"} { + if _, err := NewAzBlobClient(accountName, "", ""); err == nil { + t.Errorf("expected an error for account name %q", accountName) + } + } +} + +func TestNewAzBlobClientSharedKey(t *testing.T) { + client, err := NewAzBlobClient("testaccount", "aW52YWxpZGtleQ==", "") + if err != nil { + t.Fatalf("failed to create a shared key client: %v", err) + } + if client == nil { + t.Error("expected a client") + } +} + +func TestNewAzBlobClientSharedKeyRejectsMalformedKey(t *testing.T) { + if _, err := NewAzBlobClient("testaccount", "not base64", ""); err == nil { + t.Error("expected an error with a malformed account key") + } +} + +// no account key: authenticate through the Entra ID chain instead +func TestNewAzBlobClientEntraID(t *testing.T) { + client, err := NewAzBlobClient("testaccount", "", "") + if err != nil { + t.Fatalf("failed to create an Entra ID client: %v", err) + } + if client == nil { + t.Error("expected a client") + } +} + +func TestNewAzBlobClientWorkloadIdentity(t *testing.T) { + t.Setenv("AZURE_FEDERATED_TOKEN_FILE", filepath.Join(t.TempDir(), "token")) + t.Setenv("AZURE_TENANT_ID", "00000000-0000-0000-0000-000000000000") + + client, err := NewAzBlobClient("testaccount", "", "11111111-1111-1111-1111-111111111111") + if err != nil { + t.Fatalf("failed to create a workload identity client: %v", err) + } + if client == nil { + t.Error("expected a client") + } +} + +// a pinned client id without a projected token file is a plain managed identity +func TestNewAzureTokenCredentialManagedIdentity(t *testing.T) { + t.Setenv("AZURE_FEDERATED_TOKEN_FILE", "") + + credential, err := newAzureTokenCredential("11111111-1111-1111-1111-111111111111") + if err != nil { + t.Fatalf("failed to create a managed identity credential: %v", err) + } + if credential == nil { + t.Error("expected a credential") + } +} diff --git a/weed/remote_storage/azure/azure_storage_client.go b/weed/remote_storage/azure/azure_storage_client.go index 7f76fccb3..e0888f662 100644 --- a/weed/remote_storage/azure/azure_storage_client.go +++ b/weed/remote_storage/azure/azure_storage_client.go @@ -94,6 +94,20 @@ func init() { remote_storage.RemoteStorageClientMakers["azure"] = new(azureRemoteStorageMaker) } +// resolveAzureAccount completes the configured account from the environment. A +// configured client id asks for Entra ID, so a key left over in the environment +// must not quietly take the request back to shared key auth. +func resolveAzureAccount(conf *remote_pb.RemoteConf) (accountName, accountKey string) { + accountName, accountKey = conf.AzureAccountName, conf.AzureAccountKey + if len(accountName) == 0 { + accountName = os.Getenv("AZURE_STORAGE_ACCOUNT") + } + if len(accountKey) == 0 && len(conf.AzureClientId) == 0 { + accountKey = os.Getenv("AZURE_STORAGE_ACCESS_KEY") + } + return +} + type azureRemoteStorageMaker struct{} func (s azureRemoteStorageMaker) HasBucket() bool { @@ -106,24 +120,14 @@ func (s azureRemoteStorageMaker) Make(conf *remote_pb.RemoteConf) (remote_storag conf: conf, } - accountName, accountKey := conf.AzureAccountName, conf.AzureAccountKey - if len(accountName) == 0 || len(accountKey) == 0 { - accountName, accountKey = os.Getenv("AZURE_STORAGE_ACCOUNT"), os.Getenv("AZURE_STORAGE_ACCESS_KEY") - if len(accountName) == 0 || len(accountKey) == 0 { - return nil, fmt.Errorf("either AZURE_STORAGE_ACCOUNT or AZURE_STORAGE_ACCESS_KEY environment variable is not set") - } + accountName, accountKey := resolveAzureAccount(conf) + if len(accountName) == 0 { + return nil, fmt.Errorf("neither azure_account_name nor the AZURE_STORAGE_ACCOUNT environment variable is set") } - // Create credential and client - credential, err := azblob.NewSharedKeyCredential(accountName, accountKey) + azClient, err := NewAzBlobClient(accountName, accountKey, conf.AzureClientId) if err != nil { - return nil, fmt.Errorf("invalid Azure credential with account name:%s: %w", accountName, err) - } - - serviceURL := fmt.Sprintf("https://%s.blob.core.windows.net/", accountName) - azClient, err := azblob.NewClientWithSharedKeyCredential(serviceURL, credential, DefaultAzBlobClientOptions()) - if err != nil { - return nil, fmt.Errorf("failed to create Azure client: %w", err) + return nil, err } client.client = azClient diff --git a/weed/remote_storage/azure/azure_storage_client_test.go b/weed/remote_storage/azure/azure_storage_client_test.go index 71bbc91b6..eadba08c5 100644 --- a/weed/remote_storage/azure/azure_storage_client_test.go +++ b/weed/remote_storage/azure/azure_storage_client_test.go @@ -351,6 +351,47 @@ func TestAzureRemoteStorageMaker(t *testing.T) { } } +func TestResolveAzureAccount(t *testing.T) { + t.Setenv("AZURE_STORAGE_ACCOUNT", "envaccount") + t.Setenv("AZURE_STORAGE_ACCESS_KEY", "ZW52a2V5") + + accountName, accountKey := resolveAzureAccount(&remote_pb.RemoteConf{}) + if accountName != "envaccount" || accountKey != "ZW52a2V5" { + t.Errorf("expected the environment account, got %q/%q", accountName, accountKey) + } + + // a configured identity keeps a leftover environment key out of the way + accountName, accountKey = resolveAzureAccount(&remote_pb.RemoteConf{ + AzureAccountName: "testaccount", + AzureClientId: "11111111-1111-1111-1111-111111111111", + }) + if accountName != "testaccount" { + t.Errorf("expected the configured account name, got %q", accountName) + } + if accountKey != "" { + t.Errorf("expected no account key alongside a client id, got %q", accountKey) + } +} + +// An account name without a key authenticates with Entra ID +func TestAzureRemoteStorageMakerWithoutAccountKey(t *testing.T) { + t.Setenv("AZURE_STORAGE_ACCOUNT", "") + t.Setenv("AZURE_STORAGE_ACCESS_KEY", "") + + maker := azureRemoteStorageMaker{} + conf := &remote_pb.RemoteConf{ + Name: "test", + AzureAccountName: "testaccount", + } + client, err := maker.Make(conf) + if err != nil { + t.Fatalf("Failed to create client without an account key: %v", err) + } + if client.(*azureRemoteStorageClient).client == nil { + t.Error("Expected a blob client") + } +} + // Test error cases func TestAzureStorageClientErrors(t *testing.T) { // Test with invalid credentials diff --git a/weed/replication/sink/azuresink/azure_sink.go b/weed/replication/sink/azuresink/azure_sink.go index d73b86fc8..4ca8a9c4e 100644 --- a/weed/replication/sink/azuresink/azure_sink.go +++ b/weed/replication/sink/azuresink/azure_sink.go @@ -51,6 +51,7 @@ func (g *AzureSink) Initialize(configuration util.Configuration, prefix string) return g.initialize( configuration.GetString(prefix+"account_name"), configuration.GetString(prefix+"account_key"), + configuration.GetString(prefix+"client_id"), configuration.GetString(prefix+"container"), configuration.GetString(prefix+"directory"), ) @@ -60,20 +61,13 @@ func (g *AzureSink) SetSourceFiler(s *source.FilerSource) { g.filerSource = s } -func (g *AzureSink) initialize(accountName, accountKey, container, dir string) error { +func (g *AzureSink) initialize(accountName, accountKey, clientID, container, dir string) error { g.container = container g.dir = dir - // Create credential and client - credential, err := azblob.NewSharedKeyCredential(accountName, accountKey) + client, err := azure.NewAzBlobClient(accountName, accountKey, clientID) if err != nil { - return fmt.Errorf("failed to create Azure credential with account name:%s: %w", accountName, err) - } - - serviceURL := fmt.Sprintf("https://%s.blob.core.windows.net/", accountName) - client, err := azblob.NewClientWithSharedKeyCredential(serviceURL, credential, azure.DefaultAzBlobClientOptions()) - if err != nil { - return fmt.Errorf("failed to create Azure client: %w", err) + return err } g.client = client diff --git a/weed/replication/sink/azuresink/azure_sink_test.go b/weed/replication/sink/azuresink/azure_sink_test.go index 292e0e95b..33282df6d 100644 --- a/weed/replication/sink/azuresink/azure_sink_test.go +++ b/weed/replication/sink/azuresink/azure_sink_test.go @@ -104,7 +104,7 @@ func TestAzureSinkInitialization(t *testing.T) { sink := &AzureSink{} - err := sink.initialize(accountName, accountKey, testContainer, "/test") + err := sink.initialize(accountName, accountKey, "", testContainer, "/test") if err != nil { t.Fatalf("Failed to initialize Azure sink: %v", err) } @@ -190,7 +190,7 @@ func TestAzureSinkEntryOperations(t *testing.T) { } sink := &AzureSink{} - err := sink.initialize(accountName, accountKey, testContainer, "/test") + err := sink.initialize(accountName, accountKey, "", testContainer, "/test") if err != nil { t.Fatalf("Failed to initialize: %v", err) } @@ -285,7 +285,7 @@ func TestAzureSinkPrecondition(t *testing.T) { } sink := &AzureSink{} - err := sink.initialize(accountName, accountKey, testContainer, "/test") + err := sink.initialize(accountName, accountKey, "", testContainer, "/test") if err != nil { t.Fatalf("Failed to initialize: %v", err) } @@ -354,7 +354,7 @@ func TestAzureSinkIdempotentCreate(t *testing.T) { } sink := &AzureSink{} - err := sink.initialize(accountName, accountKey, testContainer, "/test") + err := sink.initialize(accountName, accountKey, "", testContainer, "/test") if err != nil { t.Fatalf("Failed to initialize: %v", err) } @@ -460,7 +460,7 @@ func BenchmarkCleanKey(b *testing.B) { func TestAzureSinkInvalidCredentials(t *testing.T) { sink := &AzureSink{} - err := sink.initialize("invalid-account", "aW52YWxpZGtleQ==", "test-container", "/test") + err := sink.initialize("invalid-account", "aW52YWxpZGtleQ==", "", "test-container", "/test") if err != nil { t.Skip("Invalid credentials correctly rejected at initialization") } diff --git a/weed/shell/command_remote_configure.go b/weed/shell/command_remote_configure.go index 09fce04f7..44b9c5e9f 100644 --- a/weed/shell/command_remote_configure.go +++ b/weed/shell/command_remote_configure.go @@ -38,6 +38,7 @@ func (c *commandRemoteConfigure) Help() string { remote.configure -name=cloud1_deep -type=s3 -s3.access_key=xxx -s3.secret_key=yyy -s3.region=us-east-2 -s3.storage_class=DEEP_ARCHIVE remote.configure -name=cloud2 -type=gcs -gcs.appCredentialsFile=~/service-account-file.json -gcs.projectId=yyy remote.configure -name=cloud3 -type=azure -azure.account_name=xxx -azure.account_key=yyy + remote.configure -name=cloud3 -type=azure -azure.account_name=xxx -azure.client_id=zzz remote.configure -name=cloud4 -type=aliyun -aliyun.access_key=xxx -aliyun.secret_key=yyy -aliyun.endpoint=oss-cn-shenzhen.aliyuncs.com -aliyun.region=cn-sehnzhen remote.configure -name=cloud5 -type=tencent -tencent.secret_id=xxx -tencent.secret_key=yyy -tencent.endpoint=cos.ap-guangzhou.myqcloud.com remote.configure -name=cloud6 -type=wasabi -wasabi.access_key=xxx -wasabi.secret_key=yyy -wasabi.endpoint=s3.us-west-1.wasabisys.com -wasabi.region=us-west-1 @@ -81,7 +82,8 @@ func (c *commandRemoteConfigure) Do(args []string, commandEnv *CommandEnv, write remoteConfigureCommand.StringVar(&conf.GcsProjectId, "gcs.projectId", "", "google cloud storage project id, default to use env GOOGLE_CLOUD_PROJECT") remoteConfigureCommand.StringVar(&conf.AzureAccountName, "azure.account_name", "", "azure account name, default to use env AZURE_STORAGE_ACCOUNT") - remoteConfigureCommand.StringVar(&conf.AzureAccountKey, "azure.account_key", "", "azure account name, default to use env AZURE_STORAGE_ACCESS_KEY") + remoteConfigureCommand.StringVar(&conf.AzureAccountKey, "azure.account_key", "", "azure account key, default to use env AZURE_STORAGE_ACCESS_KEY. Leave empty to authenticate with Entra ID") + remoteConfigureCommand.StringVar(&conf.AzureClientId, "azure.client_id", "", "azure user-assigned identity to authenticate, when no account key is given. Workload identity also reads env AZURE_TENANT_ID and AZURE_FEDERATED_TOKEN_FILE") remoteConfigureCommand.StringVar(&conf.BackblazeKeyId, "b2.key_id", "", "backblaze keyID") remoteConfigureCommand.StringVar(&conf.BackblazeApplicationKey, "b2.application_key", "", "backblaze applicationKey. Note that your Master Application Key will not work with the S3 Compatible API. You must create a new key that is eligible for use. For more information: https://help.backblaze.com/hc/en-us/articles/360047425453")