diff --git a/sdk/storage/azure_storage_blob/CHANGELOG.md b/sdk/storage/azure_storage_blob/CHANGELOG.md index dbb59c128c..c7a1e768ef 100644 --- a/sdk/storage/azure_storage_blob/CHANGELOG.md +++ b/sdk/storage/azure_storage_blob/CHANGELOG.md @@ -4,6 +4,9 @@ ### Features Added +- Added a SAS-only constructor to `BlobContainerClient` (`new_sas`) that builds a pipeline without a bearer token policy. This enables using SAS in the endpoint without requiring a `TokenCredential`. +- Introduced a SAS query policy that ensures SAS query parameters from the client endpoint are appended to every outgoing request. Sub-clients created via `get_blob_client` inherit this policy. + ### Breaking Changes ### Bugs Fixed diff --git a/sdk/storage/azure_storage_blob/README.md b/sdk/storage/azure_storage_blob/README.md index 59af6d0942..92e0d9394f 100644 --- a/sdk/storage/azure_storage_blob/README.md +++ b/sdk/storage/azure_storage_blob/README.md @@ -57,6 +57,37 @@ async fn main() -> Result<(), Box> { } ``` +#### SAS-only authentication + +You can use Shared Access Signatures (SAS) without providing a `TokenCredential`. The SAS-only constructors build a pipeline without a bearer-token policy and apply a SAS query policy that ensures the SAS query parameters are present on every outgoing request, even when URLs are recomposed internally. Clients created via `get_blob_client` inherit this pipeline. + +```rust no_run +use azure_core::http::{ClientOptions, Transport}; +use azure_storage_blob::{BlobContainerClient, BlobContainerClientOptions}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Endpoint must include a valid SAS query string. + let endpoint = "https://.blob.core.windows.net/?"; + let container = "container_name".to_string(); + + // Optional: inject a custom HTTP transport (e.g., reqwest). Otherwise, use defaults. + // let http = std::sync::Arc::new(reqwest::Client::builder().build()?); + // let client_options = ClientOptions { transport: Some(Transport::new(http)), ..Default::default() }; + + let client_options = ClientOptions::default(); + let options = BlobContainerClientOptions { client_options, ..Default::default() }; + + // No TokenCredential required for SAS-only. + let container_client = BlobContainerClient::new_sas(endpoint, container, Some(options))?; + + // The SAS query policy ensures SAS params are attached on every request. + // Example operation: + let _exists = container_client.exists().await?; + Ok(()) +} +``` + #### Permissions You may need to specify RBAC roles to access Blob Storage via Microsoft Entra ID. Please see [Assign an Azure role for access to blob data] for more details. @@ -115,6 +146,38 @@ async fn main() -> Result<(), Box> { } ``` +### SAS-only: Upload Blob via `BlobContainerClient` + +```rust no_run +use azure_core::http::{ClientOptions, RequestContent}; +use azure_storage_blob::{BlobContainerClient, BlobContainerClientOptions}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Endpoint must include a valid SAS query string. + let endpoint = "https://.blob.core.windows.net/?"; + let container = "container_name".to_string(); + + let options = BlobContainerClientOptions { + client_options: ClientOptions::default(), + ..Default::default() + }; + let container_client = BlobContainerClient::new_sas(endpoint, container, Some(options))?; + + // Create a per-blob client from the container client; it inherits the SAS policy. + let blob = container_client.blob_client("blob_name".to_string()); + + let data = b"hello world"; + blob.upload( + RequestContent::from(data.to_vec()), + false, // overwrite + u64::try_from(data.len())?, // content length + None, // upload options + ).await?; + Ok(()) +} +``` + ### Get Blob Properties ```rust no_run diff --git a/sdk/storage/azure_storage_blob/src/clients/blob_container_client.rs b/sdk/storage/azure_storage_blob/src/clients/blob_container_client.rs index db6cca8956..262f1f3c03 100644 --- a/sdk/storage/azure_storage_blob/src/clients/blob_container_client.rs +++ b/sdk/storage/azure_storage_blob/src/clients/blob_container_client.rs @@ -74,6 +74,41 @@ impl BlobContainerClient { }) } + /// Creates a new BlobContainerClient using SAS-only authentication. + /// + /// The `endpoint` must include a valid SAS query string; no Entra ID token is requested. + /// The pipeline is constructed without a bearer-token policy. + /// + /// # Arguments + /// + /// * `endpoint` - The full URL of the Azure storage account, for example `https://myaccount.blob.core.windows.net/` + /// with a SAS query string appended. + /// * `container_name` - The name of the container. + /// * `options` - Optional configuration for the client. + pub fn new_sas( + endpoint: &str, + container_name: String, + options: Option, + ) -> Result { + let mut options = options.unwrap_or_default(); + + // Ensure storage-specific headers are applied on each call, same as in `new`. + let storage_headers_policy = Arc::new(StorageHeadersPolicy); + options + .client_options + .per_call_policies + .push(storage_headers_policy); + + // Construct generated client without bearer token auth (SAS-only). + let client = + GeneratedBlobContainerClient::new_sas(endpoint, container_name.clone(), Some(options))?; + + Ok(Self { + endpoint: endpoint.parse()?, + client, + }) + } + /// Returns a new instance of BlobClient. /// /// # Arguments diff --git a/sdk/storage/azure_storage_blob/src/generated/clients/blob_client.rs b/sdk/storage/azure_storage_blob/src/generated/clients/blob_client.rs index 9e0b8a2586..911723d2ee 100644 --- a/sdk/storage/azure_storage_blob/src/generated/clients/blob_client.rs +++ b/sdk/storage/azure_storage_blob/src/generated/clients/blob_client.rs @@ -24,6 +24,7 @@ use crate::generated::{ BlobClientUndeleteOptions, BlobClientUndeleteResult, BlobExpiryOptions, BlobTags, }, }; +use crate::pipeline::SasQueryPolicy; use azure_core::{ base64::encode, credentials::TokenCredential, diff --git a/sdk/storage/azure_storage_blob/src/generated/clients/blob_container_client.rs b/sdk/storage/azure_storage_blob/src/generated/clients/blob_container_client.rs index 44680f01de..4a86dd204e 100644 --- a/sdk/storage/azure_storage_blob/src/generated/clients/blob_container_client.rs +++ b/sdk/storage/azure_storage_blob/src/generated/clients/blob_container_client.rs @@ -24,6 +24,7 @@ use crate::generated::{ SignedIdentifier, }, }; +use crate::pipeline::SasQueryPolicy; use azure_core::{ async_runtime::get_async_runtime, credentials::TokenCredential, @@ -101,6 +102,58 @@ impl BlobContainerClient { }) } + /// Creates a new BlobContainerClient using SAS-only authentication. + /// + /// This constructor does not add a bearer token credential policy to the pipeline. + /// The provided `endpoint` must include a valid SAS query string which will be + /// used by the service to authorize requests. + /// + /// # Arguments + /// + /// * `endpoint` - The full URL of the Azure storage account, for example: + /// `https://myaccount.blob.core.windows.net/` with a SAS query appended. + /// * `container_name` - The name of the container. + /// * `options` - Optional configuration for the client. + #[tracing::new("Storage.Blob.Container")] + pub fn new_sas( + endpoint: &str, + container_name: String, + options: Option, + ) -> Result { + let options = options.unwrap_or_default(); + let endpoint = Url::parse(endpoint)?; + if !endpoint.scheme().starts_with("http") { + return Err(azure_core::Error::with_message( + azure_core::error::ErrorKind::Other, + format!("{endpoint} must use http(s)"), + )); + } + + // Build per-call policies and ensure SAS query is present on every request. + let mut per_call: Vec> = Vec::new(); + if let Some(q) = endpoint.query() { + let sas = SasQueryPolicy::from_query_str(q); + if !sas.is_empty() { + per_call.push(Arc::new(sas)); + } + } + + // Build a pipeline without a BearerTokenCredentialPolicy; SAS on the URL is used for auth. + Ok(Self { + container_name, + endpoint, + version: options.version.clone(), + pipeline: Pipeline::new( + option_env!("CARGO_PKG_NAME"), + option_env!("CARGO_PKG_VERSION"), + options.client_options, + per_call, + Vec::new(), // no bearer auth policy for SAS-only + None, + ), + }) + } + /// Returns the Url associated with this client. pub fn endpoint(&self) -> &Url { &self.endpoint @@ -699,7 +752,7 @@ impl BlobContainerClient { Ok(rsp.into()) } - /// Returns a new instance of BlobClient. + /// Returns a new instance of BlobClient that shares this client's endpoint, pipeline (including SAS policy), and API version. /// /// # Arguments /// diff --git a/sdk/storage/azure_storage_blob/src/pipeline/mod.rs b/sdk/storage/azure_storage_blob/src/pipeline/mod.rs index 4fbf3171bd..e7fef35a69 100644 --- a/sdk/storage/azure_storage_blob/src/pipeline/mod.rs +++ b/sdk/storage/azure_storage_blob/src/pipeline/mod.rs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +mod sas_query_policy; mod storage_headers_policy; +pub use sas_query_policy::SasQueryPolicy; pub use storage_headers_policy::StorageHeadersPolicy; diff --git a/sdk/storage/azure_storage_blob/src/pipeline/sas_query_policy.rs b/sdk/storage/azure_storage_blob/src/pipeline/sas_query_policy.rs new file mode 100644 index 0000000000..7e1984f79b --- /dev/null +++ b/sdk/storage/azure_storage_blob/src/pipeline/sas_query_policy.rs @@ -0,0 +1,91 @@ +/* Copyright (c) Microsoft Corporation. + Licensed under the MIT License. */ + +use async_trait::async_trait; +use azure_core::http::{ + policies::{Policy, PolicyResult}, + Context, Request, +}; +use std::collections::HashSet; +use std::sync::Arc; + +/// A policy that ensures SAS query parameters from the client endpoint +/// are present on every outgoing request. +/// +/// This is intended for use in SAS-only client constructors. The SAS query +/// string from the endpoint is parsed at construction time and cached in the +/// policy. For each request, any missing SAS parameters are appended to the +/// request URL, preserving existing query parameters added by the operation. +/// +/// Notes: +/// - Existing query parameters are preserved and take precedence; SAS params +/// that are already present on the request will NOT be duplicated or +/// overwritten. +/// - Parameter name comparison is case-sensitive (Azure SAS parameters are +/// conventionally lower-case, e.g. `sv`, `ss`, `srt`, `sp`, `se`, `st`, +/// `spr`, `sig`). +#[derive(Debug, Clone)] +pub struct SasQueryPolicy { + pairs: Vec<(String, String)>, +} + +impl SasQueryPolicy { + /// Create a policy from a raw query string (e.g., everything after the `?`). + /// + /// If `query` is empty, the resulting policy will be a no-op. + pub fn from_query_str(query: &str) -> Self { + // Parse the query into key/value pairs, percent-decoding as necessary. + // The `append_pair` API will handle re-encoding on write. + let pairs = if query.is_empty() { + Vec::new() + } else { + url::form_urlencoded::parse(query.as_bytes()) + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect() + }; + Self { pairs } + } + + /// Returns true if the policy has no SAS parameters and will be a no-op. + pub fn is_empty(&self) -> bool { + self.pairs.is_empty() + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl Policy for SasQueryPolicy { + async fn send( + &self, + ctx: &Context, + request: &mut Request, + next: &[Arc], + ) -> PolicyResult { + // If we have nothing to append, skip quickly. + if self.pairs.is_empty() { + return next[0].send(ctx, request, &next[1..]).await; + } + + // Snapshot existing query parameter names to avoid duplicates. + // We only check for the presence of the key (name); if present, we + // do not attempt to overwrite the value. This preserves any operation- + // specific overrides (e.g., pre-existing SAS on the request). + let existing: HashSet = request + .url() + .query_pairs() + .map(|(k, _)| k.to_string()) + .collect(); + + // Append missing SAS pairs. + { + let mut qp = request.url_mut().query_pairs_mut(); + for (k, v) in &self.pairs { + if !existing.contains(k) { + qp.append_pair(k, v); + } + } + } + + next[0].send(ctx, request, &next[1..]).await + } +}