MM-68664: Microsoft Entra ID / Default Credential authentication for Azure Blob Storage (#36733)

* Add DefaultAzureCredential authentication for Azure Blob Storage

Adds a second authentication mode for the Azure filestore backend
alongside the existing shared-key path. The new "default credential"
mode constructs azidentity.NewDefaultAzureCredential, which discovers
managed identity, workload identity, service principal env vars, and
az login in that order at runtime - the standard Microsoft pattern for
host-provided identities.

The credential type is configured via FileSettings.AzureAuthMode (and
ExportAzureAuthMode for the dedicated export store). Both default to
shared_key so existing deployments are unaffected. The access-key field
is only required under shared_key; default_credential reads identity
from the host environment and needs no per-mode config.

------
AI assisted commit

* Add Azure authentication selector to the System Console

Adds an "Azure Authentication" dropdown to both the primary file-storage
panel and the dedicated export-store panel. Two options: "Shared key"
(the existing default) and "Default credential (Microsoft Entra ID)".
The Azure Storage Account Key field is hidden when default credential
is selected; it has no role in that auth mode.

The Cypress spec is extended to cover the new dropdown's visibility
toggling.

------
AI assisted commit

* Use fmt.Errorf instead of pkg/errors

* Do not support empty AzureAuthModeSharedKey

There is no need to support legacy settings when a feature is not yet
released.

* Bring in master's Azure Blob Storage Cypress spec and scroll the access key into view

Two related changes:

- The merge commit just before this one missed master's MM-68787 updates
  to the Azure Blob Storage Cypress spec (the AzureClouddropdown
  visibility, the disabled -> not.exist tightening when S3 driver is
  selected, and the new "shows the custom endpoint only for the Custom
  cloud" test). This commit pulls those in.
- The new "hides the access key when the authentication mode is default
  credential" spec asserts the access key field is visible immediately
  after selecting the Azure driver. With the AzureAuthMode and (now
  landed) AzureCloud dropdowns above it, the field sits below the
  visible area of the System Console scroll container, and Cypress's
  strict be.visible check fails on overflow clipping. scrollIntoView
  mirrors what the Test Connection spec already does for the same
  reason.

------
AI assisted commit

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Alejandro García Montoro
2026-05-27 10:57:26 +02:00
committed by GitHub
parent d563fdd5ac
commit 472e4a01d2
13 changed files with 321 additions and 40 deletions
@@ -36,6 +36,7 @@ describe('Environment - File Storage (Azure Blob Storage)', () => {
cy.findByTestId('FileSettings.AzureStorageAccountinput').should('not.be.disabled');
cy.findByTestId('FileSettings.AzureContainerinput').should('not.be.disabled');
cy.findByTestId('FileSettings.AzurePathPrefixinput').should('not.be.disabled');
cy.findByTestId('FileSettings.AzureAuthModedropdown').should('not.be.disabled');
cy.findByTestId('FileSettings.AzureAccessKeyinput').should('not.be.disabled');
cy.findByTestId('FileSettings.AzureClouddropdown').should('not.be.disabled');
cy.findByTestId('FileSettings.AzureRequestTimeoutMillisecondsnumber').should('not.be.disabled');
@@ -82,11 +83,33 @@ describe('Environment - File Storage (Azure Blob Storage)', () => {
// * Azure fields are not rendered when the driver is not Azure
cy.findByTestId('FileSettings.AzureStorageAccountinput').should('not.exist');
cy.findByTestId('FileSettings.AzureContainerinput').should('not.exist');
cy.findByTestId('FileSettings.AzureAuthModedropdown').should('not.exist');
cy.findByTestId('FileSettings.AzureAccessKeyinput').should('not.exist');
cy.findByTestId('FileSettings.AzureClouddropdown').should('not.exist');
cy.findByTestId('FileSettings.AzureEndpointinput').should('not.exist');
});
it('hides the access key when the authentication mode is default credential', () => {
// # Select the Azure driver
cy.findByTestId('FileSettings.DriverNamedropdown').select('azureblob');
// * Shared key is the default and the access key is visible
cy.findByTestId('FileSettings.AzureAuthModedropdown').should('have.value', 'shared_key');
cy.findByTestId('FileSettings.AzureAccessKeyinput').scrollIntoView().should('be.visible');
// # Switch to default credential
cy.findByTestId('FileSettings.AzureAuthModedropdown').select('default_credential');
// * The access key field is removed from the DOM
cy.findByTestId('FileSettings.AzureAccessKeyinput').should('not.exist');
// # Switch back to shared key
cy.findByTestId('FileSettings.AzureAuthModedropdown').select('shared_key');
// * The access key field reappears
cy.findByTestId('FileSettings.AzureAccessKeyinput').scrollIntoView().should('be.visible');
});
it('exposes the backend-agnostic Test Connection button when Azure is selected', () => {
// # Select the Azure driver
cy.findByTestId('FileSettings.DriverNamedropdown').select('azureblob');
+5 -1
View File
@@ -72,10 +72,12 @@ func (a *App) CheckMandatoryS3Fields(settings *model.FileSettings) *model.AppErr
func (a *App) CheckMandatoryAzureFields(settings *model.FileSettings) *model.AppError {
storageAccount := settings.AzureStorageAccount
authMode := settings.AzureAuthMode
accessKey := settings.AzureAccessKey
container := settings.AzureContainer
if a.License().IsCloud() && a.Config().FeatureFlags.CloudDedicatedExportUI && a.Config().FileSettings.DedicatedExportStore != nil && *a.Config().FileSettings.DedicatedExportStore {
storageAccount = settings.ExportAzureStorageAccount
authMode = settings.ExportAzureAuthMode
accessKey = settings.ExportAzureAccessKey
container = settings.ExportAzureContainer
}
@@ -85,7 +87,9 @@ func (a *App) CheckMandatoryAzureFields(settings *model.FileSettings) *model.App
if container == nil || *container == "" {
return model.NewAppError("CheckMandatoryAzureFields", "api.admin.test_azure.missing_azure_field", nil, "missing azure container setting", http.StatusBadRequest)
}
if accessKey == nil || *accessKey == "" {
// Access key only matters under shared-key auth. Default credential pulls
// identity from the host environment.
if authMode != nil && *authMode == model.AzureAuthModeSharedKey && (accessKey == nil || *accessKey == "") {
return model.NewAppError("CheckMandatoryAzureFields", "api.admin.test_azure.missing_azure_field", nil, "missing azure access key setting", http.StatusBadRequest)
}
return nil
+1
View File
@@ -333,6 +333,7 @@ func ConfigToFileBackendSettings(s *model.FileSettings, enableComplianceFeature
return filestore.FileBackendSettings{
DriverName: *s.DriverName,
AzureStorageAccount: *s.AzureStorageAccount,
AzureAuthMode: *s.AzureAuthMode,
AzureAccessKey: *s.AzureAccessKey,
AzureContainer: *s.AzureContainer,
AzurePathPrefix: *s.AzurePathPrefix,
+4
View File
@@ -5,6 +5,7 @@ go 1.26.3
require (
code.sajari.com/docconv/v2 v2.0.0-pre.4
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4
github.com/Masterminds/semver/v3 v3.5.0
github.com/avct/uasurfer v0.0.0-20250915105040-a942f6fb6edc
@@ -87,6 +88,7 @@ require (
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/JalfResi/justext v0.0.0-20221106200834-be571e3e3052 // indirect
github.com/PuerkitoBio/goquery v1.12.0 // indirect
github.com/STARRY-S/zip v0.2.3 // indirect
@@ -156,6 +158,7 @@ require (
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/crc32 v1.3.0 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
github.com/levigross/exp-html v0.0.0-20120902181939-8df60c69a8f5 // indirect
@@ -183,6 +186,7 @@ require (
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pierrec/lz4/v4 v4.1.26 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/redis/go-redis/v9 v9.19.0 // indirect
+7
View File
@@ -16,12 +16,16 @@ github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzU
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
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/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@@ -339,6 +343,8 @@ github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
@@ -800,6 +806,7 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+8
View File
@@ -11374,6 +11374,10 @@
"id": "model.config.is_valid.autotranslation.workers.app_error",
"translation": "Workers must be between 1 and 64."
},
{
"id": "model.config.is_valid.azure_auth_mode.app_error",
"translation": "Invalid Azure authentication mode {{.Value}}. Must be 'shared_key' or 'default_credential'."
},
{
"id": "model.config.is_valid.azure_cloud.app_error",
"translation": "Invalid value {{.Value}} for {{.Setting}}. Must be 'commercial', 'government', or 'custom'."
@@ -11570,6 +11574,10 @@
"id": "model.config.is_valid.export.retention_days_too_low.app_error",
"translation": "Invalid value for RetentionDays. Value should be greater than 0"
},
{
"id": "model.config.is_valid.export_azure_auth_mode.app_error",
"translation": "Invalid Azure authentication mode {{.Value}} for the dedicated export store. Must be 'shared_key' or 'default_credential'."
},
{
"id": "model.config.is_valid.export_azure_timeout.app_error",
"translation": "Invalid timeout value {{.Value}}. Should be a positive number."
+66 -36
View File
@@ -19,6 +19,7 @@ import (
"time"
"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/Azure/azure-sdk-for-go/sdk/storage/azblob/blob"
"github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/bloberror"
@@ -27,7 +28,6 @@ import (
"github.com/google/uuid"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
pkgerr "github.com/pkg/errors"
)
// azureBlockSize is the chunk size used when staging block blob uploads.
@@ -35,8 +35,11 @@ import (
// StageBlock call well under the per-block REST limit (4000 MiB).
const azureBlockSize = 4 * 1024 * 1024
// AzureFileBackend stores files in Azure Blob Storage. Connections are
// authenticated with a shared key today; Microsoft Entra ID is a follow-up.
// AzureFileBackend stores files in Azure Blob Storage. Two authentication
// modes are supported: shared key (an account access key configured by the
// admin) and Microsoft Entra ID via DefaultAzureCredential (managed identity,
// service principal, workload identity, or az login - whichever the host
// environment provides).
type AzureFileBackend struct {
client *azblob.Client
container string
@@ -49,11 +52,6 @@ func NewAzureFileBackend(settings FileBackendSettings) (*AzureFileBackend, error
return nil, err
}
credential, err := azblob.NewSharedKeyCredential(settings.AzureStorageAccount, settings.AzureAccessKey)
if err != nil {
return nil, pkgerr.Wrap(err, "failed to create azure shared key credential")
}
scheme := "https"
if !settings.AzureSSL {
scheme = "http"
@@ -80,9 +78,9 @@ func NewAzureFileBackend(settings FileBackendSettings) (*AzureFileBackend, error
}
}
client, err := azblob.NewClientWithSharedKeyCredential(serviceURL, credential, clientOptions)
client, err := newAzureClient(settings, serviceURL, clientOptions)
if err != nil {
return nil, pkgerr.Wrap(err, "failed to create azure blob client")
return nil, err
}
// Config.IsValid rejects non-positive timeouts before they reach this
@@ -109,6 +107,38 @@ func (b *AzureFileBackend) DriverName() string {
return driverAzure
}
// newAzureClient builds an azblob client for the configured authentication
// mode. Shared key uses NewClientWithSharedKeyCredential; default credential
// uses NewClient with DefaultAzureCredential, which discovers managed
// identity, workload identity, service principal env vars, and az login in
// that order at runtime.
func newAzureClient(settings FileBackendSettings, serviceURL string, clientOptions *azblob.ClientOptions) (*azblob.Client, error) {
switch settings.AzureAuthMode {
case model.AzureAuthModeDefaultCredential:
cred, err := azidentity.NewDefaultAzureCredential(nil)
if err != nil {
return nil, fmt.Errorf("failed to create azure default credential: %w", err)
}
client, err := azblob.NewClient(serviceURL, cred, clientOptions)
if err != nil {
return nil, fmt.Errorf("failed to create azure blob client: %w", err)
}
return client, nil
case model.AzureAuthModeSharedKey:
cred, err := azblob.NewSharedKeyCredential(settings.AzureStorageAccount, settings.AzureAccessKey)
if err != nil {
return nil, fmt.Errorf("failed to create azure shared key credential: %w", err)
}
client, err := azblob.NewClientWithSharedKeyCredential(serviceURL, cred, clientOptions)
if err != nil {
return nil, fmt.Errorf("failed to create azure blob client: %w", err)
}
return client, nil
default:
return nil, fmt.Errorf("unknown azure auth mode %q", settings.AzureAuthMode)
}
}
// buildAzureServiceURL renders the Blob service URL that the SDK signs
// requests against. The cloud value selects the topology:
//
@@ -206,12 +236,12 @@ func (b *AzureFileBackend) TestConnection() error {
return nil
}
if bloberror.HasCode(err, bloberror.ContainerNotFound) {
return &FileBackendNoBucketError{Err: pkgerr.Wrapf(err, "azure container %q does not exist", b.container)}
return &FileBackendNoBucketError{Err: fmt.Errorf("azure container %q does not exist: %w", b.container, err)}
}
if isAzureAuthError(err) {
return &FileBackendAuthError{Err: pkgerr.Wrap(err, "unable to authenticate against azure blob storage")}
return &FileBackendAuthError{Err: fmt.Errorf("unable to authenticate against azure blob storage: %w", err)}
}
return pkgerr.Wrap(err, "unable to connect to azure blob storage")
return fmt.Errorf("unable to connect to azure blob storage: %w", err)
}
// MakeContainer creates the configured container. Mirrors S3FileBackend.MakeBucket
@@ -226,7 +256,7 @@ func (b *AzureFileBackend) MakeContainer() error {
if bloberror.HasCode(err, bloberror.ContainerAlreadyExists) {
return nil
}
return pkgerr.Wrapf(err, "unable to create azure container %q", b.container)
return fmt.Errorf("unable to create azure container %q: %w", b.container, err)
}
return nil
}
@@ -244,12 +274,12 @@ func (b *AzureFileBackend) Reader(p string) (ReadCloseSeeker, error) {
if err != nil {
timer.Stop()
cancel()
return nil, pkgerr.Wrapf(err, "unable to read file %q", p)
return nil, fmt.Errorf("unable to read file %q: %w", p, err)
}
if props.ContentLength == nil {
timer.Stop()
cancel()
return nil, pkgerr.Errorf("missing content length for %q", p)
return nil, fmt.Errorf("missing content length for %q", p)
}
return &azureRangeReader{
@@ -279,7 +309,7 @@ func (b *AzureFileBackend) FileExists(p string) (bool, error) {
if bloberror.HasCode(err, bloberror.BlobNotFound) {
return false, nil
}
return false, pkgerr.Wrapf(err, "unable to check existence of %q", p)
return false, fmt.Errorf("unable to check existence of %q: %w", p, err)
}
return true, nil
}
@@ -290,7 +320,7 @@ func (b *AzureFileBackend) FileSize(p string) (int64, error) {
props, err := b.newBlobClient(p).GetProperties(ctx, nil)
if err != nil {
return 0, pkgerr.Wrapf(err, "unable to get size of %q", p)
return 0, fmt.Errorf("unable to get size of %q: %w", p, err)
}
return model.SafeDereference(props.ContentLength), nil
@@ -302,7 +332,7 @@ func (b *AzureFileBackend) FileModTime(p string) (time.Time, error) {
props, err := b.newBlobClient(p).GetProperties(ctx, nil)
if err != nil {
return time.Time{}, pkgerr.Wrapf(err, "unable to get modification time of %q", p)
return time.Time{}, fmt.Errorf("unable to get modification time of %q: %w", p, err)
}
return model.SafeDereference(props.LastModified), nil
@@ -318,7 +348,7 @@ func (b *AzureFileBackend) CopyFile(oldPath, newPath string) error {
src := b.newBlobClient(oldPath).URL()
dst := b.newBlockBlobClient(newPath)
if _, err := dst.StartCopyFromURL(ctx, src, nil); err != nil {
return pkgerr.Wrapf(err, "unable to copy %q to %q", oldPath, newPath)
return fmt.Errorf("unable to copy %q to %q: %w", oldPath, newPath, err)
}
// Poll until the copy reports success. For server-to-server copies within
@@ -327,7 +357,7 @@ func (b *AzureFileBackend) CopyFile(oldPath, newPath string) error {
for {
props, err := dst.GetProperties(ctx, nil)
if err != nil {
return pkgerr.Wrapf(err, "unable to read copy status for %q", newPath)
return fmt.Errorf("unable to read copy status for %q: %w", newPath, err)
}
if props.CopyStatus == nil {
return nil
@@ -337,11 +367,11 @@ func (b *AzureFileBackend) CopyFile(oldPath, newPath string) error {
return nil
case blob.CopyStatusTypeFailed, blob.CopyStatusTypeAborted:
desc := model.SafeDereference(props.CopyStatusDescription)
return pkgerr.Errorf("azure copy from %q to %q ended in status %q: %q", oldPath, newPath, *props.CopyStatus, desc)
return fmt.Errorf("azure copy from %q to %q ended in status %q: %q", oldPath, newPath, *props.CopyStatus, desc)
}
select {
case <-ctx.Done():
return pkgerr.Wrapf(ctx.Err(), "azure copy from %q to %q did not complete in time", oldPath, newPath)
return fmt.Errorf("azure copy from %q to %q did not complete in time: %w", oldPath, newPath, ctx.Err())
case <-time.After(50 * time.Millisecond):
}
}
@@ -374,10 +404,10 @@ func (b *AzureFileBackend) stageBlocks(ctx context.Context, bb *blockblob.Client
if n > 0 {
id, idErr := newAzureBlockID()
if idErr != nil {
return nil, 0, pkgerr.Wrap(idErr, "failed to generate azure block id")
return nil, 0, fmt.Errorf("failed to generate azure block id: %w", idErr)
}
if _, sbErr := bb.StageBlock(ctx, id, &readSeekNopCloser{Reader: bytes.NewReader(buf[:n])}, nil); sbErr != nil {
return nil, 0, pkgerr.Wrapf(sbErr, "unable to stage block for %q", p)
return nil, 0, fmt.Errorf("unable to stage block for %q: %w", p, sbErr)
}
ids = append(ids, id)
total += int64(n)
@@ -386,7 +416,7 @@ func (b *AzureFileBackend) stageBlocks(ctx context.Context, bb *blockblob.Client
break
}
if err != nil {
return nil, 0, pkgerr.Wrap(err, "failed to read input")
return nil, 0, fmt.Errorf("failed to read input: %w", err)
}
}
return ids, total, nil
@@ -416,16 +446,16 @@ func (b *AzureFileBackend) WriteFileContext(ctx context.Context, fr io.Reader, p
// committed block list so AppendFile can target it.
id, idErr := newAzureBlockID()
if idErr != nil {
return 0, pkgerr.Wrap(idErr, "failed to generate azure block id")
return 0, fmt.Errorf("failed to generate azure block id: %w", idErr)
}
if _, sbErr := bb.StageBlock(ctx, id, &readSeekNopCloser{Reader: bytes.NewReader(nil)}, nil); sbErr != nil {
return 0, pkgerr.Wrapf(sbErr, "unable to stage empty block for %q", p)
return 0, fmt.Errorf("unable to stage empty block for %q: %w", p, sbErr)
}
blockIDs = append(blockIDs, id)
}
if _, err := bb.CommitBlockList(ctx, blockIDs, nil); err != nil {
return 0, pkgerr.Wrapf(err, "unable to commit block list for %q", p)
return 0, fmt.Errorf("unable to commit block list for %q: %w", p, err)
}
return total, nil
}
@@ -450,7 +480,7 @@ func (b *AzureFileBackend) AppendFile(fr io.Reader, p string) (int64, error) {
listResp, err := bb.GetBlockList(ctx, blockblob.BlockListTypeCommitted, nil)
if err != nil {
return 0, pkgerr.Wrapf(err, "unable to find file %q to append data", p)
return 0, fmt.Errorf("unable to find file %q to append data: %w", p, err)
}
var existingIDs []string
@@ -465,10 +495,10 @@ func (b *AzureFileBackend) AppendFile(fr io.Reader, p string) (int64, error) {
if len(existingIDs) == 0 {
props, propsErr := bb.GetProperties(ctx, nil)
if propsErr != nil {
return 0, pkgerr.Wrapf(propsErr, "unable to inspect %q before append", p)
return 0, fmt.Errorf("unable to inspect %q before append: %w", p, propsErr)
}
if model.SafeDereference(props.ContentLength) > 0 {
return 0, pkgerr.Errorf("refusing to append to %q: blob has content but no committed block list (likely written via Put Blob by another tool)", p)
return 0, fmt.Errorf("refusing to append to %q: blob has content but no committed block list (likely written via Put Blob by another tool)", p)
}
}
@@ -478,7 +508,7 @@ func (b *AzureFileBackend) AppendFile(fr io.Reader, p string) (int64, error) {
}
if _, err := bb.CommitBlockList(ctx, append(existingIDs, newIDs...), nil); err != nil {
return 0, pkgerr.Wrapf(err, "unable to commit block list for %q", p)
return 0, fmt.Errorf("unable to commit block list for %q: %w", p, err)
}
return total, nil
}
@@ -489,7 +519,7 @@ func (b *AzureFileBackend) RemoveFile(p string) error {
_, err := b.newBlobClient(p).Delete(ctx, nil)
if err != nil && !bloberror.HasCode(err, bloberror.BlobNotFound) {
return pkgerr.Wrapf(err, "unable to remove file %q", p)
return fmt.Errorf("unable to remove file %q: %w", p, err)
}
return nil
}
@@ -511,7 +541,7 @@ func (b *AzureFileBackend) ListDirectory(p string) ([]string, error) {
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, pkgerr.Wrapf(err, "unable to list directory %q", p)
return nil, fmt.Errorf("unable to list directory %q: %w", p, err)
}
for _, item := range page.Segment.BlobItems {
if item.Name == nil {
@@ -551,7 +581,7 @@ func (b *AzureFileBackend) ListDirectoryRecursively(p string) ([]string, error)
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return nil, pkgerr.Wrapf(err, "unable to list directory %q recursively", p)
return nil, fmt.Errorf("unable to list directory %q recursively: %w", p, err)
}
for _, item := range page.Segment.BlobItems {
if item.Name == nil {
@@ -223,6 +223,7 @@ func azuriteSettings(t *testing.T) FileBackendSettings {
return FileBackendSettings{
DriverName: driverAzure,
AzureStorageAccount: azuriteWellKnownAccount,
AzureAuthMode: model.AzureAuthModeSharedKey,
AzureAccessKey: azuriteWellKnownKey,
AzureContainer: "mattermost-test",
AzureCloud: model.AzureCloudCustom,
@@ -231,6 +232,82 @@ func azuriteSettings(t *testing.T) FileBackendSettings {
}
}
func TestNewAzureFileBackendAuthMode(t *testing.T) {
base := FileBackendSettings{
DriverName: driverAzure,
AzureStorageAccount: "anaccount",
AzureContainer: "acontainer",
AzureEndpoint: "localhost:10000",
AzureSSL: false,
AzureRequestTimeoutMilliseconds: 30000,
}
t.Run("shared_key constructs a client", func(t *testing.T) {
s := base
s.AzureAuthMode = model.AzureAuthModeSharedKey
s.AzureAccessKey = azuriteWellKnownKey
be, err := NewAzureFileBackend(s)
require.NoError(t, err)
require.NotNil(t, be.client)
})
t.Run("default_credential constructs a client without an access key", func(t *testing.T) {
s := base
s.AzureAuthMode = model.AzureAuthModeDefaultCredential
// Intentionally no AzureAccessKey - default credential reads
// identity from the host environment, not config.
be, err := NewAzureFileBackend(s)
require.NoError(t, err)
require.NotNil(t, be.client)
})
t.Run("empty AuthMode is rejected", func(t *testing.T) {
s := base
s.AzureAuthMode = ""
s.AzureAccessKey = azuriteWellKnownKey
_, err := NewAzureFileBackend(s)
require.Error(t, err)
require.Contains(t, err.Error(), "unknown azure auth mode")
})
t.Run("unknown AuthMode is rejected", func(t *testing.T) {
s := base
s.AzureAuthMode = "oauth2"
s.AzureAccessKey = azuriteWellKnownKey
_, err := NewAzureFileBackend(s)
require.Error(t, err)
require.Contains(t, err.Error(), "unknown azure auth mode")
})
}
func TestCheckMandatoryAzureFieldsAuthMode(t *testing.T) {
base := FileBackendSettings{
AzureStorageAccount: "anaccount",
AzureContainer: "acontainer",
}
t.Run("shared_key requires access key", func(t *testing.T) {
s := base
s.AzureAuthMode = model.AzureAuthModeSharedKey
s.AzureAccessKey = ""
require.Error(t, s.CheckMandatoryAzureFields())
s.AzureAccessKey = "somekey"
require.NoError(t, s.CheckMandatoryAzureFields())
})
t.Run("default_credential does not require access key", func(t *testing.T) {
s := base
s.AzureAuthMode = model.AzureAuthModeDefaultCredential
s.AzureAccessKey = ""
require.NoError(t, s.CheckMandatoryAzureFields())
})
}
func TestAzureFileBackendTestSuite(t *testing.T) {
suite.Run(t, &FileBackendTestSuite{settings: azuriteSettings(t)})
}
@@ -67,6 +67,7 @@ type FileBackendSettings struct {
AmazonS3UploadPartSizeBytes int64
AmazonS3StorageClass string
AzureStorageAccount string
AzureAuthMode string
AzureAccessKey string
AzureContainer string
AzurePathPrefix string
@@ -87,6 +88,7 @@ func NewFileBackendSettingsFromConfig(fileSettings *model.FileSettings, enableCo
return FileBackendSettings{
DriverName: *fileSettings.DriverName,
AzureStorageAccount: *fileSettings.AzureStorageAccount,
AzureAuthMode: *fileSettings.AzureAuthMode,
AzureAccessKey: *fileSettings.AzureAccessKey,
AzureContainer: *fileSettings.AzureContainer,
AzurePathPrefix: *fileSettings.AzurePathPrefix,
@@ -127,6 +129,7 @@ func NewExportFileBackendSettingsFromConfig(fileSettings *model.FileSettings, en
return FileBackendSettings{
DriverName: *fileSettings.ExportDriverName,
AzureStorageAccount: *fileSettings.ExportAzureStorageAccount,
AzureAuthMode: *fileSettings.ExportAzureAuthMode,
AzureAccessKey: *fileSettings.ExportAzureAccessKey,
AzureContainer: *fileSettings.ExportAzureContainer,
AzurePathPrefix: *fileSettings.ExportAzurePathPrefix,
@@ -177,7 +180,11 @@ func (settings *FileBackendSettings) CheckMandatoryAzureFields() error {
if settings.AzureContainer == "" {
return errors.New("missing azure container setting")
}
if settings.AzureAccessKey == "" {
// AzureAccessKey is only meaningful for shared-key auth. Default credential
// reads identity from the host environment (managed identity / workload
// identity / service principal env vars / az login), so an empty access key
// is the expected configuration in that mode.
if settings.AzureAuthMode == model.AzureAuthModeSharedKey && settings.AzureAccessKey == "" {
return errors.New("missing azure access key setting")
}
return nil
+21
View File
@@ -38,6 +38,9 @@ const (
ImageDriverS3 = "amazons3"
ImageDriverAzure = "azureblob"
AzureAuthModeSharedKey = "shared_key"
AzureAuthModeDefaultCredential = "default_credential"
// AzureCloudCommercial / AzureCloudGovernment select hardcoded Azure
// service endpoints so admins do not have to spell out the suffix
// for the well-known clouds. AzureCloudCustom hands control to the
@@ -1814,6 +1817,7 @@ type FileSettings struct {
AmazonS3UploadPartSizeBytes *int64 `access:"environment_file_storage,write_restrictable,cloud_restrictable"` // telemetry: none
AmazonS3StorageClass *string `access:"environment_file_storage,write_restrictable,cloud_restrictable"` // telemetry: none
AzureStorageAccount *string `access:"environment_file_storage,write_restrictable,cloud_restrictable"` // telemetry: none
AzureAuthMode *string `access:"environment_file_storage,write_restrictable,cloud_restrictable"` // telemetry: none
AzureAccessKey *string `access:"environment_file_storage,write_restrictable,cloud_restrictable"` // telemetry: none
AzureContainer *string `access:"environment_file_storage,write_restrictable,cloud_restrictable"` // telemetry: none
AzurePathPrefix *string `access:"environment_file_storage,write_restrictable,cloud_restrictable"` // telemetry: none
@@ -1840,6 +1844,7 @@ type FileSettings struct {
ExportAmazonS3UploadPartSizeBytes *int64 `access:"environment_file_storage,write_restrictable"` // telemetry: none
ExportAmazonS3StorageClass *string `access:"environment_file_storage,write_restrictable"` // telemetry: none
ExportAzureStorageAccount *string `access:"environment_file_storage,write_restrictable"` // telemetry: none
ExportAzureAuthMode *string `access:"environment_file_storage,write_restrictable"` // telemetry: none
ExportAzureAccessKey *string `access:"environment_file_storage,write_restrictable"` // telemetry: none
ExportAzureContainer *string `access:"environment_file_storage,write_restrictable"` // telemetry: none
ExportAzurePathPrefix *string `access:"environment_file_storage,write_restrictable"` // telemetry: none
@@ -1967,6 +1972,10 @@ func (s *FileSettings) SetDefaults(isUpdate bool) {
s.AzureStorageAccount = NewPointer("")
}
if s.AzureAuthMode == nil {
s.AzureAuthMode = NewPointer(AzureAuthModeSharedKey)
}
if s.AzureAccessKey == nil {
s.AzureAccessKey = NewPointer("")
}
@@ -2069,6 +2078,10 @@ func (s *FileSettings) SetDefaults(isUpdate bool) {
s.ExportAzureStorageAccount = NewPointer("")
}
if s.ExportAzureAuthMode == nil {
s.ExportAzureAuthMode = NewPointer(AzureAuthModeSharedKey)
}
if s.ExportAzureAccessKey == nil {
s.ExportAzureAccessKey = NewPointer("")
}
@@ -4584,6 +4597,10 @@ func (s *FileSettings) isValid() *AppError {
return NewAppError("Config.IsValid", "model.config.is_valid.azure_timeout.app_error", map[string]any{"Value": *s.AzureRequestTimeoutMilliseconds}, "", http.StatusBadRequest)
}
if !(*s.AzureAuthMode == AzureAuthModeSharedKey || *s.AzureAuthMode == AzureAuthModeDefaultCredential) {
return NewAppError("Config.IsValid", "model.config.is_valid.azure_auth_mode.app_error", map[string]any{"Value": *s.AzureAuthMode}, "", http.StatusBadRequest)
}
switch *s.AzureCloud {
case AzureCloudCommercial, AzureCloudGovernment, AzureCloudCustom:
default:
@@ -4618,6 +4635,10 @@ func (s *FileSettings) isValid() *AppError {
return NewAppError("Config.IsValid", "model.config.is_valid.export_azure_timeout.app_error", map[string]any{"Value": *s.ExportAzureRequestTimeoutMilliseconds}, "", http.StatusBadRequest)
}
if !(*s.ExportAzureAuthMode == AzureAuthModeSharedKey || *s.ExportAzureAuthMode == AzureAuthModeDefaultCredential) {
return NewAppError("Config.IsValid", "model.config.is_valid.export_azure_auth_mode.app_error", map[string]any{"Value": *s.ExportAzureAuthMode}, "", http.StatusBadRequest)
}
switch *s.ExportAzureCloud {
case AzureCloudCommercial, AzureCloudGovernment, AzureCloudCustom:
default:
+40
View File
@@ -322,6 +322,46 @@ func TestFileSettingsAzureRequestTimeoutBounds(t *testing.T) {
}
}
func TestFileSettingsAzureAuthMode(t *testing.T) {
t.Run("defaults to shared_key", func(t *testing.T) {
cfg := &Config{}
cfg.SetDefaults()
require.NotNil(t, cfg.FileSettings.AzureAuthMode)
require.NotNil(t, cfg.FileSettings.ExportAzureAuthMode)
assert.Equal(t, AzureAuthModeSharedKey, *cfg.FileSettings.AzureAuthMode)
assert.Equal(t, AzureAuthModeSharedKey, *cfg.FileSettings.ExportAzureAuthMode)
})
t.Run("default_credential is accepted", func(t *testing.T) {
cfg := &Config{}
cfg.SetDefaults()
cfg.FileSettings.AzureAuthMode = NewPointer(AzureAuthModeDefaultCredential)
cfg.FileSettings.ExportAzureAuthMode = NewPointer(AzureAuthModeDefaultCredential)
assert.Nil(t, cfg.FileSettings.isValid())
})
t.Run("unknown primary mode is rejected", func(t *testing.T) {
cfg := &Config{}
cfg.SetDefaults()
cfg.FileSettings.AzureAuthMode = NewPointer("oauth2")
err := cfg.FileSettings.isValid()
require.NotNil(t, err)
assert.Equal(t, "model.config.is_valid.azure_auth_mode.app_error", err.Id)
})
t.Run("unknown export mode is rejected", func(t *testing.T) {
cfg := &Config{}
cfg.SetDefaults()
cfg.FileSettings.ExportAzureAuthMode = NewPointer("oauth2")
err := cfg.FileSettings.isValid()
require.NotNil(t, err)
assert.Equal(t, "model.config.is_valid.export_azure_auth_mode.app_error", err.Id)
})
}
func TestFileSettingsAzureCloudValidation(t *testing.T) {
t.Run("unknown cloud values are rejected", func(t *testing.T) {
cases := []struct {
@@ -138,6 +138,8 @@ export {it};
const FILE_STORAGE_DRIVER_LOCAL = 'local';
const FILE_STORAGE_DRIVER_S3 = 'amazons3';
const FILE_STORAGE_DRIVER_AZURE = 'azureblob';
const AZURE_AUTH_MODE_SHARED_KEY = 'shared_key';
const AZURE_AUTH_MODE_DEFAULT_CREDENTIAL = 'default_credential';
const AZURE_CLOUD_COMMERCIAL = 'commercial';
const AZURE_CLOUD_GOVERNMENT = 'government';
const AZURE_CLOUD_CUSTOM = 'custom';
@@ -1405,6 +1407,28 @@ const AdminDefinition: AdminDefinitionType = {
),
isHidden: it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_AZURE)),
},
{
type: 'dropdown',
key: 'FileSettings.AzureAuthMode',
label: defineMessage({id: 'admin.image.azureAuthModeTitle', defaultMessage: 'Azure Authentication:'}),
help_text: defineMessage({id: 'admin.image.azureAuthModeDescription', defaultMessage: '"Shared key" signs requests with the Storage Account access key.\n \n"Default credential (Microsoft Entra ID)" reads the identity from the host environment - managed identity on Azure-hosted deployments, workload identity, service principal env vars, or "az login" for local development. No access key required.'}), // eslint-disable-line formatjs/no-multiple-whitespaces
help_text_markdown: true,
options: [
{
value: AZURE_AUTH_MODE_SHARED_KEY,
display_name: defineMessage({id: 'admin.image.azureAuthModeSharedKey', defaultMessage: 'Shared key'}),
},
{
value: AZURE_AUTH_MODE_DEFAULT_CREDENTIAL,
display_name: defineMessage({id: 'admin.image.azureAuthModeDefaultCredential', defaultMessage: 'Default credential (Microsoft Entra ID)'}),
},
],
isDisabled: it.any(
it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.FILE_STORAGE)),
it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_AZURE)),
),
isHidden: it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_AZURE)),
},
{
type: 'text',
key: 'FileSettings.AzureAccessKey',
@@ -1414,8 +1438,12 @@ const AdminDefinition: AdminDefinitionType = {
isDisabled: it.any(
it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.FILE_STORAGE)),
it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_AZURE)),
it.not(it.stateEquals('FileSettings.AzureAuthMode', AZURE_AUTH_MODE_SHARED_KEY)),
),
isHidden: it.any(
it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_AZURE)),
it.not(it.stateEquals('FileSettings.AzureAuthMode', AZURE_AUTH_MODE_SHARED_KEY)),
),
isHidden: it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_AZURE)),
},
{
type: 'text',
@@ -1724,6 +1752,28 @@ const AdminDefinition: AdminDefinitionType = {
),
isHidden: it.any(it.not(it.stateEquals('FileSettings.ExportDriverName', FILE_STORAGE_DRIVER_AZURE)), it.stateEquals('FileSettings.DedicatedExportStore', false)),
},
{
type: 'dropdown',
key: 'FileSettings.ExportAzureAuthMode',
label: defineMessage({id: 'admin.image.azureAuthModeTitle', defaultMessage: 'Azure Authentication:'}),
help_text: defineMessage({id: 'admin.image.azureAuthModeDescription', defaultMessage: '"Shared key" signs requests with the Storage Account access key.\n \n"Default credential (Microsoft Entra ID)" reads the identity from the host environment - managed identity on Azure-hosted deployments, workload identity, service principal env vars, or "az login" for local development. No access key required.'}), // eslint-disable-line formatjs/no-multiple-whitespaces
help_text_markdown: true,
options: [
{
value: AZURE_AUTH_MODE_SHARED_KEY,
display_name: defineMessage({id: 'admin.image.azureAuthModeSharedKey', defaultMessage: 'Shared key'}),
},
{
value: AZURE_AUTH_MODE_DEFAULT_CREDENTIAL,
display_name: defineMessage({id: 'admin.image.azureAuthModeDefaultCredential', defaultMessage: 'Default credential (Microsoft Entra ID)'}),
},
],
isDisabled: it.any(
it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.FILE_STORAGE)),
it.stateEquals('FileSettings.DedicatedExportStore', false),
),
isHidden: it.any(it.not(it.stateEquals('FileSettings.ExportDriverName', FILE_STORAGE_DRIVER_AZURE)), it.stateEquals('FileSettings.DedicatedExportStore', false)),
},
{
type: 'text',
key: 'FileSettings.ExportAzureAccessKey',
@@ -1733,8 +1783,13 @@ const AdminDefinition: AdminDefinitionType = {
isDisabled: it.any(
it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.FILE_STORAGE)),
it.stateEquals('FileSettings.DedicatedExportStore', false),
it.not(it.stateEquals('FileSettings.ExportAzureAuthMode', AZURE_AUTH_MODE_SHARED_KEY)),
),
isHidden: it.any(
it.not(it.stateEquals('FileSettings.ExportDriverName', FILE_STORAGE_DRIVER_AZURE)),
it.stateEquals('FileSettings.DedicatedExportStore', false),
it.not(it.stateEquals('FileSettings.ExportAzureAuthMode', AZURE_AUTH_MODE_SHARED_KEY)),
),
isHidden: it.any(it.not(it.stateEquals('FileSettings.ExportDriverName', FILE_STORAGE_DRIVER_AZURE)), it.stateEquals('FileSettings.DedicatedExportStore', false)),
},
{
type: 'text',
+4
View File
@@ -1507,6 +1507,10 @@
"admin.image.azureAccessKeyDescription": "The shared key for your Azure Storage account.",
"admin.image.azureAccessKeyExample": "E.g.: \"9MZbtYgfq18PJ8PbRaJ5u91IH8izHvReTbcuQzMl+So=\"",
"admin.image.azureAccessKeyTitle": "Azure Storage Account Key:",
"admin.image.azureAuthModeDefaultCredential": "Default credential (Microsoft Entra ID)",
"admin.image.azureAuthModeDescription": "\"Shared key\" signs requests with the Storage Account access key.\n \n\"Default credential (Microsoft Entra ID)\" reads the identity from the host environment - managed identity on Azure-hosted deployments, workload identity, service principal env vars, or \"az login\" for local development. No access key required.",
"admin.image.azureAuthModeSharedKey": "Shared key",
"admin.image.azureAuthModeTitle": "Azure Authentication:",
"admin.image.azureCloudCommercial": "Azure Commercial",
"admin.image.azureCloudCustom": "Custom Endpoint",
"admin.image.azureCloudDescription": "The Azure cloud to connect to. Choose \"Azure Commercial\" or \"Azure Government\" to use the well-known endpoint for that cloud; only the storage account name is required. Choose \"Custom Endpoint\" to point at an arbitrary host such as Azurite, a reverse proxy, or any other Azure cloud (for example Azure China).",