You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opendal.apache.org by xu...@apache.org on 2026/08/05 06:58:19 UTC

(opendal-reqsign) 01/01: feat(azure): add Service SAS granter

This is an automated email from the ASF dual-hosted git repository.

Xuanwo pushed a commit to branch xuanwo/azure-service-sas-granter
in repository https://gitbox.apache.org/repos/asf/opendal-reqsign.git

commit 5fbb7eeebca1f8ab3dd3e22be12d1297485faa05
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Wed Aug 5 14:57:47 2026 +0800

    feat(azure): add Service SAS granter
---
 reqsign/src/azure.rs                              |   32 +
 services/azure-storage/README.md                  |   39 +
 services/azure-storage/src/credential.rs          |   59 +-
 services/azure-storage/src/lib.rs                 |    6 +-
 services/azure-storage/src/service_sas.rs         | 1362 ++++++++++++++++++++-
 services/azure-storage/src/sign_request.rs        |   14 +-
 services/azure-storage/src/user_delegation_sas.rs |    4 +-
 7 files changed, 1451 insertions(+), 65 deletions(-)

diff --git a/reqsign/src/azure.rs b/reqsign/src/azure.rs
index 1f4267f..e385d48 100644
--- a/reqsign/src/azure.rs
+++ b/reqsign/src/azure.rs
@@ -19,6 +19,38 @@
 //!
 //! This module provides Azure Storage signing functionality along with convenience
 //! functions for common use cases.
+//!
+//! Shared Key credentials can also grant a typed Blob Storage Service SAS:
+//!
+//! ```no_run
+//! use std::time::Duration;
+//!
+//! use reqsign::azure::{
+//!     ServiceSasBlobPermissions, ServiceSasConfig, ServiceSasGrant,
+//!     ServiceSasGranter, StaticCredentialProvider,
+//! };
+//! use reqsign::{Context, Granter};
+//!
+//! # async fn example() -> reqsign::Result<()> {
+//! let grant = ServiceSasGrant::for_blob(
+//!     "customer-data",
+//!     "reports/current.csv",
+//!     ServiceSasBlobPermissions::READ,
+//! );
+//! let credential = Granter::new(
+//!     Context::new(),
+//!     StaticCredentialProvider::new_shared_key(
+//!         "mystorageaccount",
+//!         "<base64-encoded 512-bit account key>",
+//!     ),
+//!     ServiceSasGranter::new(ServiceSasConfig::new("mystorageaccount"), grant),
+//! )
+//! .grant(Some(Duration::from_secs(900)))
+//! .await?;
+//! # let _ = credential;
+//! # Ok(())
+//! # }
+//! ```
 
 // Re-export all Azure Storage signing types
 pub use reqsign_azure_storage::*;
diff --git a/services/azure-storage/README.md b/services/azure-storage/README.md
index c6d37d2..1d7ed6f 100644
--- a/services/azure-storage/README.md
+++ b/services/azure-storage/README.md
@@ -105,6 +105,45 @@ let static_provider = StaticCredentialProvider::new_shared_key(
 );
 ```
 
+## Grant a Service SAS
+
+Use a Shared Key source to mint an independently usable, expiration-aware SAS
+for one typed container or blob grant:
+
+```rust
+use std::time::Duration;
+
+use reqsign_azure_storage::{
+    ServiceSasBlobPermissions, ServiceSasConfig, ServiceSasGrant,
+    ServiceSasGranter, StaticCredentialProvider,
+};
+use reqsign_core::{Context, Granter};
+
+# async fn example() -> reqsign_core::Result<()> {
+let grant = ServiceSasGrant::for_blob(
+    "customer-data",
+    "reports/current.csv",
+    ServiceSasBlobPermissions::READ,
+);
+let credential = Granter::new(
+    Context::new(),
+    StaticCredentialProvider::new_shared_key(
+        "mystorageaccount",
+        "<base64-encoded 512-bit account key>",
+    ),
+    ServiceSasGranter::new(ServiceSasConfig::new("mystorageaccount"), grant),
+)
+.grant(Some(Duration::from_secs(900)))
+.await?;
+# let _ = credential;
+# Ok(())
+# }
+```
+
+The configured account must match the Shared Key source. The returned
+`Credential::SasToken` can be supplied directly to the existing Azure
+`RequestSigner`.
+
 ## Examples
 
 Run the example:
diff --git a/services/azure-storage/src/credential.rs b/services/azure-storage/src/credential.rs
index 81e34b9..18eb4da 100644
--- a/services/azure-storage/src/credential.rs
+++ b/services/azure-storage/src/credential.rs
@@ -17,10 +17,11 @@
 
 use reqsign_core::SigningCredential;
 use reqsign_core::time::Timestamp;
-use reqsign_core::utils::Redact;
 use std::fmt::{Debug, Formatter};
 use std::time::Duration;
 
+const REDACTED: &str = "REDACTED";
+
 /// Credential enum for different Azure Storage authentication methods.
 #[derive(Clone)]
 pub enum Credential {
@@ -50,22 +51,19 @@ pub enum Credential {
 impl Debug for Credential {
     fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
         match self {
-            Credential::SharedKey {
-                account_name,
-                account_key,
-            } => f
+            Credential::SharedKey { .. } => f
                 .debug_struct("Credential::SharedKey")
-                .field("account_name", &Redact::from(account_name))
-                .field("account_key", &Redact::from(account_key))
+                .field("account_name", &REDACTED)
+                .field("account_key", &REDACTED)
                 .finish(),
-            Credential::SasToken { token, expires_at } => f
+            Credential::SasToken { expires_at, .. } => f
                 .debug_struct("Credential::SasToken")
-                .field("token", &Redact::from(token))
+                .field("token", &REDACTED)
                 .field("expires_at", expires_at)
                 .finish(),
-            Credential::BearerToken { token, expires_in } => f
+            Credential::BearerToken { expires_in, .. } => f
                 .debug_struct("Credential::BearerToken")
-                .field("token", &Redact::from(token))
+                .field("token", &REDACTED)
                 .field("expires_in", expires_in)
                 .finish(),
         }
@@ -157,4 +155,43 @@ mod tests {
         assert!(credential.is_valid_at(now + Duration::from_secs(5)));
         assert!(!credential.is_valid_at(now + Duration::from_secs(10)));
     }
+
+    #[test]
+    fn debug_fully_redacts_credential_material() {
+        let debug = [
+            format!(
+                "{:?}",
+                Credential::with_shared_key(
+                    "accountprefixmiddlesuffixaccount",
+                    "keyprefix-middle-keysuffix",
+                )
+            ),
+            format!(
+                "{:?}",
+                Credential::with_sas_token("sv=2020-12-06&sig=sasprefix-middle-sassignaturesuffix",)
+            ),
+            format!(
+                "{:?}",
+                Credential::with_bearer_token("bearerprefix-middle-bearersuffix", None,)
+            ),
+        ]
+        .join("\n");
+
+        assert!(debug.contains("Credential::SharedKey"));
+        assert!(debug.contains("Credential::SasToken"));
+        assert!(debug.contains("Credential::BearerToken"));
+        assert!(debug.contains(REDACTED));
+        for fragment in [
+            "accountprefix",
+            "suffixaccount",
+            "keyprefix",
+            "keysuffix",
+            "sasprefix",
+            "sassignaturesuffix",
+            "bearerprefix",
+            "bearersuffix",
+        ] {
+            assert!(!debug.contains(fragment));
+        }
+    }
 }
diff --git a/services/azure-storage/src/lib.rs b/services/azure-storage/src/lib.rs
index 65973c0..5a4e3b7 100644
--- a/services/azure-storage/src/lib.rs
+++ b/services/azure-storage/src/lib.rs
@@ -178,7 +178,11 @@ mod user_delegation_sas;
 mod credential;
 pub use credential::Credential;
 
-pub use service_sas::{ServiceSasResource, ServiceSharedAccessSignature};
+pub use service_sas::{
+    SasIpRange, ServiceSasBlobPermissions, ServiceSasConfig, ServiceSasContainerPermissions,
+    ServiceSasGrant, ServiceSasGranter, ServiceSasResource, ServiceSasVersion,
+    ServiceSharedAccessSignature,
+};
 pub use user_delegation_sas::{
     SasProtocol, UserDelegationSasGrant, UserDelegationSasGranter, UserDelegationSasPermissions,
 };
diff --git a/services/azure-storage/src/service_sas.rs b/services/azure-storage/src/service_sas.rs
index f465131..7ca97d0 100644
--- a/services/azure-storage/src/service_sas.rs
+++ b/services/azure-storage/src/service_sas.rs
@@ -15,15 +15,50 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use reqsign_core::Result;
+use crate::Credential;
+use crate::constants::AZURE_QUERY_ENCODE_SET;
+use crate::user_delegation_sas::SasProtocol;
+use percent_encoding::percent_encode;
 use reqsign_core::hash;
 use reqsign_core::time::Timestamp;
+use reqsign_core::{Context, Error, GrantCredential, Result, SigningCredential};
+use std::fmt::{Debug, Formatter};
+use std::net::Ipv4Addr;
+use std::ops::{BitOr, BitOrAssign};
+use std::time::Duration;
 
 const SERVICE_SAS_VERSION: &str = "2020-12-06";
 const BLOB_SERVICE: &str = "blob";
+const STORAGE_ACCOUNT_KEY_LENGTH: usize = 64;
+const SUPPORTED_SERVICE_SAS_VERSIONS: &[&str] = &[
+    "2020-12-06",
+    "2021-02-12",
+    "2021-04-10",
+    "2021-06-08",
+    "2021-08-06",
+    "2021-10-04",
+    "2021-12-02",
+    "2022-11-02",
+    "2023-01-03",
+    "2023-05-03",
+    "2023-08-03",
+    "2023-11-03",
+    "2024-02-04",
+    "2024-05-04",
+    "2024-08-04",
+    "2024-11-04",
+    "2025-01-05",
+    "2025-05-05",
+    "2025-07-05",
+    "2025-11-05",
+    "2026-02-06",
+    "2026-04-06",
+    "2026-06-06",
+    "2026-10-06",
+];
 
-/// Resource level for Azure Storage Service SAS.
-#[derive(Clone, Debug, PartialEq, Eq)]
+/// Resource level for Azure Blob Storage Service SAS.
+#[derive(Clone, PartialEq, Eq)]
 pub enum ServiceSasResource {
     /// A container resource.
     Container { container: String },
@@ -31,6 +66,12 @@ pub enum ServiceSasResource {
     Blob { container: String, blob: String },
 }
 
+impl Debug for ServiceSasResource {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        f.write_str("ServiceSasResource(REDACTED)")
+    }
+}
+
 impl ServiceSasResource {
     /// Build a resource from a request path.
     ///
@@ -41,7 +82,7 @@ impl ServiceSasResource {
 
         let container = segments
             .next()
-            .ok_or_else(|| reqsign_core::Error::request_invalid("missing container in path"))?
+            .ok_or_else(|| Error::request_invalid("missing container in path"))?
             .to_string();
 
         let rest = segments.collect::<Vec<_>>();
@@ -74,6 +115,507 @@ impl ServiceSasResource {
     }
 }
 
+/// Permissions for a container-scoped Blob Storage Service SAS.
+///
+/// Combining values with `|` produces Azure's canonical permission order.
+#[derive(Clone, Copy, Default, PartialEq, Eq)]
+pub struct ServiceSasContainerPermissions(u16);
+
+impl ServiceSasContainerPermissions {
+    /// Read blobs in the container.
+    pub const READ: Self = Self(1 << 0);
+    /// Add blocks to append blobs.
+    pub const ADD: Self = Self(1 << 1);
+    /// Create blobs.
+    pub const CREATE: Self = Self(1 << 2);
+    /// Write blob data and metadata.
+    pub const WRITE: Self = Self(1 << 3);
+    /// Delete blobs.
+    pub const DELETE: Self = Self(1 << 4);
+    /// Delete blob versions.
+    pub const DELETE_VERSION: Self = Self(1 << 5);
+    /// Permanently delete blob snapshots or versions.
+    pub const PERMANENT_DELETE: Self = Self(1 << 6);
+    /// List blobs.
+    pub const LIST: Self = Self(1 << 7);
+    /// Read or write blob index tags.
+    pub const TAGS: Self = Self(1 << 8);
+    /// Find blobs by index tags.
+    pub const FILTER_BY_TAGS: Self = Self(1 << 9);
+    /// Move blobs in hierarchical-namespace accounts.
+    pub const MOVE: Self = Self(1 << 10);
+    /// Read system properties or POSIX ACLs.
+    pub const EXECUTE: Self = Self(1 << 11);
+    /// Set the owner or owning group for hierarchical namespace resources.
+    pub const OWNERSHIP: Self = Self(1 << 12);
+    /// Set POSIX permissions or ACLs for hierarchical namespace resources.
+    pub const PERMISSIONS: Self = Self(1 << 13);
+    /// Set an immutability policy or legal hold.
+    pub const SET_IMMUTABILITY_POLICY: Self = Self(1 << 14);
+
+    /// Return whether no permission is selected.
+    pub const fn is_empty(self) -> bool {
+        self.0 == 0
+    }
+
+    /// Return whether all permissions in `other` are selected.
+    pub const fn contains(self, other: Self) -> bool {
+        self.0 & other.0 == other.0
+    }
+
+    fn as_canonical_string(self) -> String {
+        [
+            (Self::READ, 'r'),
+            (Self::ADD, 'a'),
+            (Self::CREATE, 'c'),
+            (Self::WRITE, 'w'),
+            (Self::DELETE, 'd'),
+            (Self::DELETE_VERSION, 'x'),
+            (Self::PERMANENT_DELETE, 'y'),
+            (Self::LIST, 'l'),
+            (Self::TAGS, 't'),
+            (Self::FILTER_BY_TAGS, 'f'),
+            (Self::MOVE, 'm'),
+            (Self::EXECUTE, 'e'),
+            (Self::OWNERSHIP, 'o'),
+            (Self::PERMISSIONS, 'p'),
+            (Self::SET_IMMUTABILITY_POLICY, 'i'),
+        ]
+        .into_iter()
+        .filter_map(|(permission, symbol)| self.contains(permission).then_some(symbol))
+        .collect()
+    }
+}
+
+impl Debug for ServiceSasContainerPermissions {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        f.write_str("ServiceSasContainerPermissions(REDACTED)")
+    }
+}
+
+impl BitOr for ServiceSasContainerPermissions {
+    type Output = Self;
+
+    fn bitor(self, rhs: Self) -> Self::Output {
+        Self(self.0 | rhs.0)
+    }
+}
+
+impl BitOrAssign for ServiceSasContainerPermissions {
+    fn bitor_assign(&mut self, rhs: Self) {
+        self.0 |= rhs.0;
+    }
+}
+
+/// Permissions for a blob-scoped Blob Storage Service SAS.
+///
+/// Combining values with `|` produces Azure's canonical permission order.
+#[derive(Clone, Copy, Default, PartialEq, Eq)]
+pub struct ServiceSasBlobPermissions(u16);
+
+impl ServiceSasBlobPermissions {
+    /// Read the blob.
+    pub const READ: Self = Self(1 << 0);
+    /// Add blocks to an append blob.
+    pub const ADD: Self = Self(1 << 1);
+    /// Create the blob.
+    pub const CREATE: Self = Self(1 << 2);
+    /// Write blob data and metadata.
+    pub const WRITE: Self = Self(1 << 3);
+    /// Delete the blob.
+    pub const DELETE: Self = Self(1 << 4);
+    /// Delete a blob version.
+    pub const DELETE_VERSION: Self = Self(1 << 5);
+    /// Permanently delete a blob snapshot or version.
+    pub const PERMANENT_DELETE: Self = Self(1 << 6);
+    /// Read or write blob index tags.
+    pub const TAGS: Self = Self(1 << 7);
+    /// Move the blob in hierarchical-namespace accounts.
+    pub const MOVE: Self = Self(1 << 8);
+    /// Read system properties or POSIX ACLs.
+    pub const EXECUTE: Self = Self(1 << 9);
+    /// Set the owner or owning group for hierarchical namespace resources.
+    pub const OWNERSHIP: Self = Self(1 << 10);
+    /// Set POSIX permissions or ACLs for hierarchical namespace resources.
+    pub const PERMISSIONS: Self = Self(1 << 11);
+    /// Set an immutability policy or legal hold.
+    pub const SET_IMMUTABILITY_POLICY: Self = Self(1 << 12);
+
+    /// Return whether no permission is selected.
+    pub const fn is_empty(self) -> bool {
+        self.0 == 0
+    }
+
+    /// Return whether all permissions in `other` are selected.
+    pub const fn contains(self, other: Self) -> bool {
+        self.0 & other.0 == other.0
+    }
+
+    fn as_canonical_string(self) -> String {
+        [
+            (Self::READ, 'r'),
+            (Self::ADD, 'a'),
+            (Self::CREATE, 'c'),
+            (Self::WRITE, 'w'),
+            (Self::DELETE, 'd'),
+            (Self::DELETE_VERSION, 'x'),
+            (Self::PERMANENT_DELETE, 'y'),
+            (Self::TAGS, 't'),
+            (Self::MOVE, 'm'),
+            (Self::EXECUTE, 'e'),
+            (Self::OWNERSHIP, 'o'),
+            (Self::PERMISSIONS, 'p'),
+            (Self::SET_IMMUTABILITY_POLICY, 'i'),
+        ]
+        .into_iter()
+        .filter_map(|(permission, symbol)| self.contains(permission).then_some(symbol))
+        .collect()
+    }
+}
+
+impl Debug for ServiceSasBlobPermissions {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        f.write_str("ServiceSasBlobPermissions(REDACTED)")
+    }
+}
+
+impl BitOr for ServiceSasBlobPermissions {
+    type Output = Self;
+
+    fn bitor(self, rhs: Self) -> Self::Output {
+        Self(self.0 | rhs.0)
+    }
+}
+
+impl BitOrAssign for ServiceSasBlobPermissions {
+    fn bitor_assign(&mut self, rhs: Self) {
+        self.0 |= rhs.0;
+    }
+}
+
+#[derive(Clone)]
+enum ServiceSasGrantResource {
+    Container {
+        container: String,
+        permissions: ServiceSasContainerPermissions,
+    },
+    Blob {
+        container: String,
+        blob: String,
+        permissions: ServiceSasBlobPermissions,
+    },
+}
+
+/// One complete, typed Blob Storage Service SAS grant.
+#[derive(Clone)]
+pub struct ServiceSasGrant {
+    resource: ServiceSasGrantResource,
+}
+
+impl Debug for ServiceSasGrant {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("ServiceSasGrant").finish_non_exhaustive()
+    }
+}
+
+impl ServiceSasGrant {
+    /// Grant permissions over one container and its blobs.
+    pub fn for_container(
+        container: impl Into<String>,
+        permissions: ServiceSasContainerPermissions,
+    ) -> Self {
+        Self {
+            resource: ServiceSasGrantResource::Container {
+                container: container.into(),
+                permissions,
+            },
+        }
+    }
+
+    /// Grant permissions over one exact blob.
+    pub fn for_blob(
+        container: impl Into<String>,
+        blob: impl Into<String>,
+        permissions: ServiceSasBlobPermissions,
+    ) -> Self {
+        Self {
+            resource: ServiceSasGrantResource::Blob {
+                container: container.into(),
+                blob: blob.into(),
+                permissions,
+            },
+        }
+    }
+
+    fn validate(&self) -> Result<ValidatedGrant> {
+        match &self.resource {
+            ServiceSasGrantResource::Container {
+                container,
+                permissions,
+            } => {
+                validate_container(container)?;
+                if permissions.is_empty() {
+                    return Err(Error::request_invalid(
+                        "Azure Service SAS container permissions must not be empty",
+                    ));
+                }
+                Ok(ValidatedGrant {
+                    resource: ServiceSasResource::Container {
+                        container: container.clone(),
+                    },
+                    permissions: permissions.as_canonical_string(),
+                })
+            }
+            ServiceSasGrantResource::Blob {
+                container,
+                blob,
+                permissions,
+            } => {
+                validate_container(container)?;
+                validate_blob(blob)?;
+                if permissions.is_empty() {
+                    return Err(Error::request_invalid(
+                        "Azure Service SAS blob permissions must not be empty",
+                    ));
+                }
+                Ok(ValidatedGrant {
+                    resource: ServiceSasResource::Blob {
+                        container: container.clone(),
+                        blob: blob.clone(),
+                    },
+                    permissions: permissions.as_canonical_string(),
+                })
+            }
+        }
+    }
+}
+
+struct ValidatedGrant {
+    resource: ServiceSasResource,
+    permissions: String,
+}
+
+impl Debug for ValidatedGrant {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("ValidatedGrant").finish_non_exhaustive()
+    }
+}
+
+/// One IPv4 address or inclusive IPv4 range for an Azure SAS.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub struct SasIpRange {
+    start: Ipv4Addr,
+    end: Option<Ipv4Addr>,
+}
+
+impl SasIpRange {
+    /// Restrict requests to one IPv4 address.
+    pub const fn address(address: Ipv4Addr) -> Self {
+        Self {
+            start: address,
+            end: None,
+        }
+    }
+
+    /// Restrict requests to an inclusive IPv4 range.
+    pub fn range(start: Ipv4Addr, end: Ipv4Addr) -> Result<Self> {
+        if u32::from(start) > u32::from(end) {
+            return Err(Error::request_invalid(
+                "SAS IPv4 range start must not exceed its end",
+            ));
+        }
+        Ok(Self {
+            start,
+            end: Some(end),
+        })
+    }
+
+    fn as_string(self) -> String {
+        self.end.map_or_else(
+            || self.start.to_string(),
+            |end| format!("{}-{end}", self.start),
+        )
+    }
+}
+
+impl From<Ipv4Addr> for SasIpRange {
+    fn from(value: Ipv4Addr) -> Self {
+        Self::address(value)
+    }
+}
+
+impl Debug for SasIpRange {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        f.write_str("SasIpRange(REDACTED)")
+    }
+}
+
+/// Azure Storage service version used to authorize a Service SAS.
+///
+/// This type accepts documented Azure Storage versions from `2020-12-06`
+/// onward, whose Blob Storage Service SAS string-to-sign includes the signed
+/// encryption-scope field. Unknown future versions are rejected until their
+/// wire contract is understood by this crate.
+#[derive(Clone, PartialEq, Eq)]
+pub struct ServiceSasVersion(String);
+
+impl ServiceSasVersion {
+    /// Validate and create a supported Azure Storage service version.
+    pub fn new(version: impl Into<String>) -> Result<Self> {
+        let version = version.into();
+        if !SUPPORTED_SERVICE_SAS_VERSIONS.contains(&version.as_str()) {
+            return Err(Error::config_invalid(
+                "Azure Service SAS version is not supported by this signer",
+            ));
+        }
+        Ok(Self(version))
+    }
+
+    /// Return the service version in Azure wire format.
+    pub fn as_str(&self) -> &str {
+        &self.0
+    }
+}
+
+impl Default for ServiceSasVersion {
+    fn default() -> Self {
+        Self(SERVICE_SAS_VERSION.to_string())
+    }
+}
+
+impl Debug for ServiceSasVersion {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        f.debug_tuple("ServiceSasVersion").field(&self.0).finish()
+    }
+}
+
+/// Stable configuration for locally generating one Blob Storage Service SAS.
+#[derive(Clone)]
+pub struct ServiceSasConfig {
+    account: String,
+    start: Option<Timestamp>,
+    ip: Option<SasIpRange>,
+    protocol: SasProtocol,
+    version: ServiceSasVersion,
+}
+
+impl Debug for ServiceSasConfig {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("ServiceSasConfig").finish_non_exhaustive()
+    }
+}
+
+impl ServiceSasConfig {
+    /// Create configuration bound to one Azure storage account.
+    pub fn new(account: impl Into<String>) -> Self {
+        Self {
+            account: account.into(),
+            start: None,
+            ip: None,
+            protocol: SasProtocol::Https,
+            version: ServiceSasVersion::default(),
+        }
+    }
+
+    /// Set an optional SAS start time.
+    ///
+    /// The rounded wire value must not be later than the time of granting,
+    /// because the returned credential must be immediately usable.
+    pub fn with_start(mut self, start: Timestamp) -> Self {
+        self.start = Some(start);
+        self
+    }
+
+    /// Restrict the SAS to an IPv4 address or inclusive range.
+    pub fn with_ip(mut self, ip: SasIpRange) -> Self {
+        self.ip = Some(ip);
+        self
+    }
+
+    /// Set the protocol restriction embedded in the SAS.
+    pub fn with_protocol(mut self, protocol: SasProtocol) -> Self {
+        self.protocol = protocol;
+        self
+    }
+
+    /// Set the Azure Storage service version embedded in the SAS.
+    pub fn with_version(mut self, version: ServiceSasVersion) -> Self {
+        self.version = version;
+        self
+    }
+
+    fn validate(&self) -> Result<ValidatedConfig> {
+        validate_account(&self.account)?;
+        Ok(ValidatedConfig {
+            account: self.account.clone(),
+            start: self.start,
+            ip: self.ip.map(SasIpRange::as_string),
+            protocol: self.protocol,
+            version: self.version.clone(),
+        })
+    }
+}
+
+struct ValidatedConfig {
+    account: String,
+    start: Option<Timestamp>,
+    ip: Option<String>,
+    protocol: SasProtocol,
+    version: ServiceSasVersion,
+}
+
+fn validate_account(account: &str) -> Result<()> {
+    if !(3..=24).contains(&account.len())
+        || !account
+            .bytes()
+            .all(|value| value.is_ascii_lowercase() || value.is_ascii_digit())
+    {
+        return Err(Error::config_invalid(
+            "Azure storage account name must be 3-24 lowercase ASCII letters or digits",
+        ));
+    }
+    Ok(())
+}
+
+fn validate_container(container: &str) -> Result<()> {
+    if matches!(container, "$root" | "$web" | "$logs") {
+        return Ok(());
+    }
+    if !(3..=63).contains(&container.len())
+        || !container
+            .bytes()
+            .all(|value| value.is_ascii_lowercase() || value.is_ascii_digit() || value == b'-')
+        || !container
+            .as_bytes()
+            .first()
+            .is_some_and(u8::is_ascii_alphanumeric)
+        || !container
+            .as_bytes()
+            .last()
+            .is_some_and(u8::is_ascii_alphanumeric)
+        || container.contains("--")
+    {
+        return Err(Error::request_invalid(
+            "invalid Azure Blob Storage container name",
+        ));
+    }
+    Ok(())
+}
+
+fn validate_blob(blob: &str) -> Result<()> {
+    if blob.is_empty()
+        || blob.chars().count() > 1024
+        || blob
+            .chars()
+            .any(|value| value.is_control() || value == '\\')
+    {
+        return Err(Error::request_invalid(
+            "blob name must be a non-empty percent-decoded name without control characters or backslashes",
+        ));
+    }
+    Ok(())
+}
+
 /// Service SAS generator using Shared Key.
 ///
 /// Reference: <https://learn.microsoft.com/en-us/rest/api/storageservices/create-service-sas>
@@ -136,42 +678,34 @@ impl ServiceSharedAccessSignature {
         self
     }
 
-    fn signature(&self) -> Result<String> {
-        let canonicalized_resource = self.resource.canonicalized_resource(&self.account);
-
-        // Signed identifier (si), snapshot time, encryption scope, response headers are not
-        // supported for now. Keep them empty.
-        let string_to_sign = format!(
-            "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}",
-            self.permissions,
+    fn string_to_sign(&self) -> String {
+        [
+            self.permissions.clone(),
             self.start
-                .as_ref()
-                .map_or("".to_string(), |v| v.format_rfc3339_zulu()),
+                .map_or_else(String::new, Timestamp::format_rfc3339_zulu),
             self.expiry.format_rfc3339_zulu(),
-            canonicalized_resource,
-            "",                                        // si
-            self.ip.clone().unwrap_or_default(),       // sip
-            self.protocol.clone().unwrap_or_default(), // spr
-            self.version,                              // sv
-            self.resource.signed_resource(),           // sr
-            "",                                        // snapshot time
-            "",                                        // encryption scope
-            "",                                        // rscc
-            "",                                        // rscd
-            "",                                        // rsce
-            "",                                        // rscl
-            "",                                        // rsct
-        );
+            self.resource.canonicalized_resource(&self.account),
+            String::new(), // signed identifier
+            self.ip.clone().unwrap_or_default(),
+            self.protocol.clone().unwrap_or_default(),
+            self.version.clone(),
+            self.resource.signed_resource().to_string(),
+            String::new(), // snapshot time
+            String::new(), // encryption scope
+            String::new(), // rscc
+            String::new(), // rscd
+            String::new(), // rsce
+            String::new(), // rscl
+            String::new(), // rsct
+        ]
+        .join("\n")
+    }
 
-        let decoded_key = hash::base64_decode(&self.key)?;
-        Ok(hash::base64_hmac_sha256(
-            &decoded_key,
-            string_to_sign.as_bytes(),
-        ))
+    fn signature_with_decoded_key(&self, decoded_key: &[u8]) -> String {
+        hash::base64_hmac_sha256(decoded_key, self.string_to_sign().as_bytes())
     }
 
-    /// Generate SAS query parameters.
-    pub fn token(&self) -> Result<Vec<(String, String)>> {
+    fn token_with_decoded_key(&self, decoded_key: &[u8]) -> Vec<(String, String)> {
         let mut elements: Vec<(String, String)> = vec![
             ("sv".to_string(), self.version.to_string()),
             ("se".to_string(), self.expiry.format_rfc3339_zulu()),
@@ -192,18 +726,287 @@ impl ServiceSharedAccessSignature {
             elements.push(("spr".to_string(), protocol.to_string()))
         }
 
-        let sig = self.signature()?;
-        elements.push(("sig".to_string(), sig));
+        elements.push((
+            "sig".to_string(),
+            self.signature_with_decoded_key(decoded_key),
+        ));
+
+        elements
+    }
 
-        Ok(elements)
+    /// Generate SAS query parameters.
+    pub fn token(&self) -> Result<Vec<(String, String)>> {
+        let decoded_key = hash::base64_decode(&self.key)?;
+        Ok(self.token_with_decoded_key(&decoded_key))
     }
 }
 
+pub(crate) fn encode_query_pairs(pairs: &[(String, String)]) -> String {
+    pairs
+        .iter()
+        .map(|(key, value)| {
+            format!(
+                "{}={}",
+                percent_encode(key.as_bytes(), &AZURE_QUERY_ENCODE_SET),
+                percent_encode(value.as_bytes(), &AZURE_QUERY_ENCODE_SET)
+            )
+        })
+        .collect::<Vec<_>>()
+        .join("&")
+}
+
+struct OperationTimes {
+    now: Timestamp,
+    start: Option<Timestamp>,
+    expiry: Timestamp,
+}
+
+/// Grants expiration-aware Azure Blob Storage Service SAS credentials.
+///
+/// The source credential must be [`Credential::SharedKey`] for exactly the
+/// account bound in [`ServiceSasConfig`], with a Base64-encoded 512-bit Azure
+/// account key. Service SAS generation is local and performs no network I/O.
+/// `expires_in` must be explicit and non-zero. Azure wire timestamps have
+/// whole-second precision: explicit start times are rounded forward,
+/// expirations are rounded backward, and the returned credential expiration
+/// exactly matches the signed `se` value.
+#[derive(Clone)]
+pub struct ServiceSasGranter {
+    config: ServiceSasConfig,
+    grant: ServiceSasGrant,
+    #[cfg(test)]
+    time: Option<Timestamp>,
+    #[cfg(test)]
+    time_after_generation: Option<Timestamp>,
+}
+
+impl Debug for ServiceSasGranter {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("ServiceSasGranter").finish_non_exhaustive()
+    }
+}
+
+impl ServiceSasGranter {
+    /// Create a granter for a bound account configuration and typed grant.
+    pub fn new(config: ServiceSasConfig, grant: ServiceSasGrant) -> Self {
+        Self {
+            config,
+            grant,
+            #[cfg(test)]
+            time: None,
+            #[cfg(test)]
+            time_after_generation: None,
+        }
+    }
+
+    /// Replace the bound resource and permission grant.
+    pub fn with_grant(mut self, grant: ServiceSasGrant) -> Self {
+        self.grant = grant;
+        self
+    }
+
+    #[cfg(test)]
+    fn with_time(mut self, time: Timestamp) -> Self {
+        self.time = Some(time);
+        self
+    }
+
+    #[cfg(test)]
+    fn with_time_after_generation(mut self, time: Timestamp) -> Self {
+        self.time_after_generation = Some(time);
+        self
+    }
+
+    fn now(&self) -> Timestamp {
+        #[cfg(test)]
+        if let Some(time) = self.time {
+            return time;
+        }
+        Timestamp::now()
+    }
+
+    fn now_after_generation(&self) -> Timestamp {
+        #[cfg(test)]
+        if let Some(time) = self.time_after_generation {
+            return time;
+        }
+        #[cfg(test)]
+        if let Some(time) = self.time {
+            return time;
+        }
+        Timestamp::now()
+    }
+
+    fn operation_times(
+        &self,
+        start: Option<Timestamp>,
+        expires_in: Option<Duration>,
+    ) -> Result<OperationTimes> {
+        let expires_in = expires_in.ok_or_else(|| {
+            Error::request_invalid("Azure Service SAS requires an explicit lifetime")
+        })?;
+        if expires_in.is_zero() {
+            return Err(Error::request_invalid(
+                "Azure Service SAS lifetime must be greater than zero",
+            ));
+        }
+
+        let now = self.now();
+        let expiry = expiry_at_wire_second(now, expires_in)?;
+        if expiry <= now {
+            return Err(Error::request_invalid(
+                "Azure Service SAS lifetime does not reach a future wire timestamp",
+            ));
+        }
+        let start = start.map(ceil_to_wire_second).transpose()?;
+        if start.is_some_and(|start| start > now) {
+            return Err(Error::request_invalid(
+                "Azure Service SAS start time must not be in the future",
+            ));
+        }
+        if start.is_some_and(|start| start >= expiry) {
+            return Err(Error::request_invalid(
+                "Azure Service SAS start time must be before its expiration",
+            ));
+        }
+
+        Ok(OperationTimes { now, start, expiry })
+    }
+}
+
+impl GrantCredential for ServiceSasGranter {
+    type Credential = Credential;
+
+    fn required_valid_until(
+        &self,
+        _credential: &Self::Credential,
+        _expires_in: Option<Duration>,
+    ) -> Timestamp {
+        self.now()
+    }
+
+    async fn grant_credential(
+        &self,
+        _ctx: &Context,
+        credential: &Self::Credential,
+        expires_in: Option<Duration>,
+    ) -> Result<Self::Credential> {
+        let Credential::SharedKey {
+            account_name,
+            account_key,
+        } = credential
+        else {
+            return Err(Error::credential_invalid(
+                "Azure Service SAS requires a Shared Key source credential",
+            ));
+        };
+
+        let config = self.config.validate()?;
+        if account_name != &config.account {
+            return Err(Error::credential_invalid(
+                "Azure Shared Key source account does not match the Service SAS account",
+            ));
+        }
+        if account_key.is_empty() {
+            return Err(Error::credential_invalid(
+                "Azure Service SAS source account key is missing",
+            ));
+        }
+        let decoded_key = hash::base64_decode(account_key).map_err(|_| {
+            Error::credential_invalid("Azure Service SAS source account key is invalid")
+        })?;
+        if decoded_key.len() != STORAGE_ACCOUNT_KEY_LENGTH {
+            return Err(Error::credential_invalid(
+                "Azure Service SAS source account key is invalid",
+            ));
+        }
+
+        let grant = self.grant.validate()?;
+        let times = self.operation_times(config.start, expires_in)?;
+        if !credential.is_valid_at(times.now) {
+            return Err(Error::credential_invalid(
+                "Azure Service SAS source credential is not currently usable",
+            ));
+        }
+
+        let mut signer = ServiceSharedAccessSignature::new(
+            config.account,
+            String::new(),
+            grant.resource,
+            grant.permissions,
+            times.expiry,
+        )
+        .with_protocol(config.protocol.as_str())
+        .with_version(config.version.as_str());
+        if let Some(start) = times.start {
+            signer = signer.with_start(start);
+        }
+        if let Some(ip) = config.ip {
+            signer = signer.with_ip(ip);
+        }
+
+        let pairs = signer.token_with_decoded_key(&decoded_key);
+        let token = encode_query_pairs(&pairs);
+        let after_generation = self.now_after_generation();
+        if times.expiry <= after_generation {
+            return Err(Error::request_invalid(
+                "Azure Service SAS expired before granting completed",
+            ));
+        }
+
+        let output = Credential::with_sas_token_expires_at(&token, times.expiry);
+        if !output.is_valid_at(after_generation) {
+            return Err(Error::credential_invalid(
+                "granted Azure Service SAS is not currently usable",
+            ));
+        }
+        Ok(output)
+    }
+}
+
+fn expiry_at_wire_second(now: Timestamp, expires_in: Duration) -> Result<Timestamp> {
+    let duration_seconds = i64::try_from(expires_in.as_secs()).map_err(|_| {
+        Error::request_invalid("Azure Service SAS lifetime exceeds the wire time range")
+    })?;
+    let fractional = i64::from(now.subsec_nanosecond())
+        .checked_add(i64::from(expires_in.subsec_nanos()))
+        .ok_or_else(|| {
+            Error::request_invalid("Azure Service SAS lifetime exceeds the wire time range")
+        })?;
+    let carry = fractional.div_euclid(1_000_000_000);
+    let expiry_second = now
+        .as_second()
+        .checked_add(duration_seconds)
+        .and_then(|value| value.checked_add(carry))
+        .ok_or_else(|| {
+            Error::request_invalid("Azure Service SAS lifetime exceeds the wire time range")
+        })?;
+    Timestamp::from_second(expiry_second).map_err(|_| {
+        Error::request_invalid("Azure Service SAS lifetime exceeds the wire time range")
+    })
+}
+
+fn ceil_to_wire_second(timestamp: Timestamp) -> Result<Timestamp> {
+    let mut second = timestamp.as_second();
+    if timestamp.subsec_nanosecond() > 0 {
+        second = second.checked_add(1).ok_or_else(|| {
+            Error::request_invalid("Azure Service SAS timestamp exceeds the wire time range")
+        })?;
+    }
+    Timestamp::from_second(second).map_err(|_| {
+        Error::request_invalid("Azure Service SAS timestamp exceeds the wire time range")
+    })
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
+    use crate::{RequestSigner, StaticCredentialProvider};
+    use reqsign_core::{ErrorKind, Granter, SignRequest, Signer};
     use std::str::FromStr;
-    use std::time::Duration;
+
+    const VALID_ACCOUNT_KEY: &str =
+        "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
 
     fn test_time() -> Timestamp {
         Timestamp::from_str("2022-03-01T08:12:34Z").unwrap()
@@ -257,4 +1060,485 @@ mod tests {
             }
         );
     }
+
+    fn timestamp(value: &str) -> Timestamp {
+        value.parse().expect("timestamp must parse")
+    }
+
+    fn shared_key(account: &str, decoded_key: &str) -> Credential {
+        Credential::with_shared_key(account, &hash::base64_encode(decoded_key.as_bytes()))
+    }
+
+    fn output_parts(credential: &Credential) -> (&str, Timestamp) {
+        match credential {
+            Credential::SasToken {
+                token,
+                expires_at: Some(expires_at),
+            } => (token, *expires_at),
+            other => panic!("expected expiring SAS credential, got {other:?}"),
+        }
+    }
+
+    fn query_value(token: &str, key: &str) -> Option<String> {
+        form_urlencoded::parse(token.as_bytes())
+            .find_map(|(candidate, value)| (candidate == key).then(|| value.into_owned()))
+    }
+
+    #[test]
+    fn typed_permissions_use_resource_specific_canonical_order() {
+        let container_permissions = ServiceSasContainerPermissions::READ
+            | ServiceSasContainerPermissions::ADD
+            | ServiceSasContainerPermissions::CREATE
+            | ServiceSasContainerPermissions::WRITE
+            | ServiceSasContainerPermissions::DELETE
+            | ServiceSasContainerPermissions::DELETE_VERSION
+            | ServiceSasContainerPermissions::PERMANENT_DELETE
+            | ServiceSasContainerPermissions::LIST
+            | ServiceSasContainerPermissions::TAGS
+            | ServiceSasContainerPermissions::FILTER_BY_TAGS
+            | ServiceSasContainerPermissions::MOVE
+            | ServiceSasContainerPermissions::EXECUTE
+            | ServiceSasContainerPermissions::OWNERSHIP
+            | ServiceSasContainerPermissions::PERMISSIONS
+            | ServiceSasContainerPermissions::SET_IMMUTABILITY_POLICY;
+        let container = ServiceSasGrant::for_container("container", container_permissions)
+            .validate()
+            .expect("container grant must be valid");
+        assert_eq!(container.permissions, "racwdxyltfmeopi");
+        assert!(container_permissions.contains(ServiceSasContainerPermissions::LIST));
+        assert!(container_permissions.contains(ServiceSasContainerPermissions::OWNERSHIP));
+        assert!(container_permissions.contains(ServiceSasContainerPermissions::PERMISSIONS));
+
+        let blob_permissions = ServiceSasBlobPermissions::READ
+            | ServiceSasBlobPermissions::ADD
+            | ServiceSasBlobPermissions::CREATE
+            | ServiceSasBlobPermissions::WRITE
+            | ServiceSasBlobPermissions::DELETE
+            | ServiceSasBlobPermissions::DELETE_VERSION
+            | ServiceSasBlobPermissions::PERMANENT_DELETE
+            | ServiceSasBlobPermissions::TAGS
+            | ServiceSasBlobPermissions::MOVE
+            | ServiceSasBlobPermissions::EXECUTE
+            | ServiceSasBlobPermissions::OWNERSHIP
+            | ServiceSasBlobPermissions::PERMISSIONS
+            | ServiceSasBlobPermissions::SET_IMMUTABILITY_POLICY;
+        let blob = ServiceSasGrant::for_blob("container", "blob", blob_permissions)
+            .validate()
+            .expect("blob grant must be valid");
+        assert_eq!(blob.permissions, "racwdxytmeopi");
+        assert!(blob_permissions.contains(ServiceSasBlobPermissions::TAGS));
+        assert!(blob_permissions.contains(ServiceSasBlobPermissions::OWNERSHIP));
+        assert!(blob_permissions.contains(ServiceSasBlobPermissions::PERMISSIONS));
+    }
+
+    #[test]
+    fn validates_complete_grants_versions_and_ip_ranges() {
+        for grant in [
+            ServiceSasGrant::for_container("container", ServiceSasContainerPermissions::default()),
+            ServiceSasGrant::for_blob("container", "blob", ServiceSasBlobPermissions::default()),
+            ServiceSasGrant::for_container("UPPER", ServiceSasContainerPermissions::READ),
+            ServiceSasGrant::for_blob("container", "", ServiceSasBlobPermissions::READ),
+        ] {
+            assert_eq!(
+                grant
+                    .validate()
+                    .expect_err("invalid grant must fail")
+                    .kind(),
+                ErrorKind::RequestInvalid
+            );
+        }
+
+        for version in [
+            "2020-12-05",
+            "2022-10-02",
+            "2022-13-40",
+            "2022-1-01",
+            "9999-01-01",
+            "future",
+        ] {
+            assert_eq!(
+                ServiceSasVersion::new(version)
+                    .expect_err("unsupported version must fail")
+                    .kind(),
+                ErrorKind::ConfigInvalid
+            );
+        }
+        for version in ["2020-12-06", "2022-11-02", "2026-04-06"] {
+            assert_eq!(
+                ServiceSasVersion::new(version)
+                    .expect("documented version must be accepted")
+                    .as_str(),
+                version
+            );
+        }
+
+        let start = Ipv4Addr::new(198, 51, 100, 20);
+        let end = Ipv4Addr::new(198, 51, 100, 10);
+        assert_eq!(
+            SasIpRange::range(start, end)
+                .expect_err("reversed range must fail")
+                .kind(),
+            ErrorKind::RequestInvalid
+        );
+    }
+
+    #[tokio::test]
+    async fn grants_leading_slash_blob_with_hierarchical_namespace_permissions() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let operation = ServiceSasGranter::new(
+            ServiceSasConfig::new("account"),
+            ServiceSasGrant::for_blob(
+                "container",
+                "/a",
+                ServiceSasBlobPermissions::OWNERSHIP | ServiceSasBlobPermissions::PERMISSIONS,
+            ),
+        )
+        .with_time(now);
+
+        let output = operation
+            .grant_credential(
+                &Context::new(),
+                &shared_key("account", VALID_ACCOUNT_KEY),
+                Some(Duration::from_secs(60)),
+            )
+            .await
+            .expect("leading-slash blob grant must be valid");
+        let (token, expires_at) = output_parts(&output);
+
+        assert_eq!(expires_at, timestamp("2030-01-01T00:01:00Z"));
+        assert_eq!(
+            token,
+            "sv=2020-12-06&se=2030-01-01T00%3A01%3A00Z&sp=op&sr=b&spr=https&sig=fp21tjpRyr%2BTLkc5wZgehHGIbXj04QeiXTppdexgr6I%3D"
+        );
+
+        let signer = Signer::new(
+            Context::new(),
+            StaticCredentialProvider::new_sas_token_expires_at(token, expires_at),
+            RequestSigner::new(),
+        );
+        let mut parts = http::Request::get("https://account.blob.core.windows.net/container//a")
+            .body(())
+            .expect("request must build")
+            .into_parts()
+            .0;
+        signer
+            .sign(&mut parts, None)
+            .await
+            .expect("existing signer must consume the leading-slash blob SAS");
+        assert_eq!(
+            parts.uri.to_string(),
+            format!("https://account.blob.core.windows.net/container//a?{token}")
+        );
+    }
+
+    #[tokio::test]
+    async fn grants_independently_computed_wire_vector() {
+        let now = timestamp("2030-01-01T00:00:01.500Z");
+        let start = timestamp("2030-01-01T00:00:00.750Z");
+        let expiry = timestamp("2030-01-01T00:00:03Z");
+        let ip = SasIpRange::range(
+            Ipv4Addr::new(198, 51, 100, 10),
+            Ipv4Addr::new(198, 51, 100, 20),
+        )
+        .expect("range must be ordered");
+        let version = ServiceSasVersion::new("2022-11-02").expect("service version must be valid");
+        let config = ServiceSasConfig::new("account")
+            .with_start(start)
+            .with_ip(ip)
+            .with_protocol(SasProtocol::HttpsAndHttp)
+            .with_version(version);
+        let grant = ServiceSasGrant::for_blob(
+            "container",
+            "path/to/blob name.txt",
+            ServiceSasBlobPermissions::WRITE | ServiceSasBlobPermissions::READ,
+        );
+        let operation = ServiceSasGranter::new(config, grant).with_time(now);
+        let source = shared_key("account", VALID_ACCOUNT_KEY);
+
+        let output = operation
+            .grant_credential(&Context::new(), &source, Some(Duration::from_millis(1_500)))
+            .await
+            .expect("Service SAS grant must succeed");
+        let (token, expires_at) = output_parts(&output);
+
+        assert_eq!(expires_at, expiry);
+        assert_eq!(
+            token,
+            "sv=2022-11-02&se=2030-01-01T00%3A00%3A03Z&sp=rw&sr=b&st=2030-01-01T00%3A00%3A01Z&sip=198.51.100.10-198.51.100.20&spr=https%2Chttp&sig=57MGNuddYiw/hojfFxQkcz1qgFd8klPpi31BmqTAb28%3D"
+        );
+        assert_eq!(
+            query_value(token, "sig").as_deref(),
+            Some("57MGNuddYiw/hojfFxQkcz1qgFd8klPpi31BmqTAb28=")
+        );
+        assert!(!token.contains(VALID_ACCOUNT_KEY));
+
+        let direct = ServiceSharedAccessSignature::new(
+            "account".to_string(),
+            hash::base64_encode(VALID_ACCOUNT_KEY.as_bytes()),
+            ServiceSasResource::Blob {
+                container: "container".to_string(),
+                blob: "path/to/blob name.txt".to_string(),
+            },
+            "rw".to_string(),
+            expiry,
+        )
+        .with_start(timestamp("2030-01-01T00:00:01Z"))
+        .with_ip("198.51.100.10-198.51.100.20")
+        .with_protocol("https,http")
+        .with_version("2022-11-02");
+        assert_eq!(
+            direct.string_to_sign(),
+            "rw\n2030-01-01T00:00:01Z\n2030-01-01T00:00:03Z\n/blob/account/container/path/to/blob name.txt\n\n198.51.100.10-198.51.100.20\nhttps,http\n2022-11-02\nb\n\n\n\n\n\n\n"
+        );
+        assert_eq!(
+            encode_query_pairs(&direct.token().expect("compatibility signer must succeed")),
+            token
+        );
+    }
+
+    #[tokio::test]
+    async fn grants_typed_container_permissions_and_encodes_signature() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let operation = ServiceSasGranter::new(
+            ServiceSasConfig::new("account"),
+            ServiceSasGrant::for_container(
+                "container",
+                ServiceSasContainerPermissions::LIST | ServiceSasContainerPermissions::READ,
+            ),
+        )
+        .with_time(now);
+
+        let output = operation
+            .grant_credential(
+                &Context::new(),
+                &shared_key("account", VALID_ACCOUNT_KEY),
+                Some(Duration::from_secs(60)),
+            )
+            .await
+            .expect("container Service SAS grant must succeed");
+        let (token, expires_at) = output_parts(&output);
+
+        assert_eq!(expires_at, timestamp("2030-01-01T00:01:00Z"));
+        assert_eq!(
+            token,
+            "sv=2020-12-06&se=2030-01-01T00%3A01%3A00Z&sp=rl&sr=c&spr=https&sig=BInNJ1Os6PeLCwLFrQzDgvuM0ZGsYFuIzWyD/%2BzONkk%3D"
+        );
+    }
+
+    #[tokio::test]
+    async fn rejects_source_variants_account_mismatch_and_invalid_key() {
+        let now = timestamp("2030-01-01T00:00:00Z");
+        let operation = ServiceSasGranter::new(
+            ServiceSasConfig::new("account"),
+            ServiceSasGrant::for_blob("container", "blob", ServiceSasBlobPermissions::READ),
+        )
+        .with_time(now);
+
+        let invalid_sources = [
+            Credential::with_bearer_token("bearer-secret", None),
+            Credential::with_sas_token("sv=secret&sig=sas-secret"),
+            shared_key("differentaccount", VALID_ACCOUNT_KEY),
+            Credential::with_shared_key("account", "invalid-key-secret%%%"),
+            Credential::with_shared_key("account", &hash::base64_encode(b"short-key")),
+            Credential::with_shared_key("account", ""),
+        ];
+        for source in invalid_sources {
+            let err = operation
+                .grant_credential(&Context::new(), &source, Some(Duration::from_secs(60)))
+                .await
+                .expect_err("invalid source must fail");
+            assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
+            let debug = format!("{err:?}");
+            for secret in [
+                "bearer-secret",
+                "sas-secret",
+                "differentaccount",
+                VALID_ACCOUNT_KEY,
+                "invalid-key-secret",
+            ] {
+                assert!(!debug.contains(secret));
+            }
+        }
+    }
+
+    #[tokio::test]
+    async fn validates_lifetime_deadline_and_post_generation_usability() {
+        let now = timestamp("2030-01-01T00:00:00.500Z");
+        let source = shared_key("account", VALID_ACCOUNT_KEY);
+        let operation = ServiceSasGranter::new(
+            ServiceSasConfig::new("account"),
+            ServiceSasGrant::for_blob("container", "blob", ServiceSasBlobPermissions::READ),
+        )
+        .with_time(now);
+
+        assert_eq!(operation.required_valid_until(&source, None), now);
+        for lifetime in [
+            None,
+            Some(Duration::ZERO),
+            Some(Duration::from_millis(400)),
+            Some(Duration::from_secs(u64::MAX)),
+        ] {
+            let err = operation
+                .grant_credential(&Context::new(), &source, lifetime)
+                .await
+                .expect_err("invalid lifetime must fail");
+            assert_eq!(err.kind(), ErrorKind::RequestInvalid);
+        }
+
+        let start_after_expiry = ServiceSasGranter::new(
+            ServiceSasConfig::new("account").with_start(timestamp("2030-01-01T00:01:01Z")),
+            ServiceSasGrant::for_blob("container", "blob", ServiceSasBlobPermissions::READ),
+        )
+        .with_time(now);
+        assert_eq!(
+            start_after_expiry
+                .grant_credential(&Context::new(), &source, Some(Duration::from_secs(60)),)
+                .await
+                .expect_err("start at or after expiry must fail")
+                .kind(),
+            ErrorKind::RequestInvalid
+        );
+
+        let future_start = ServiceSasGranter::new(
+            ServiceSasConfig::new("account").with_start(timestamp("2030-01-01T00:00:01Z")),
+            ServiceSasGrant::for_blob("container", "blob", ServiceSasBlobPermissions::READ),
+        )
+        .with_time(now);
+        assert_eq!(
+            future_start
+                .grant_credential(&Context::new(), &source, Some(Duration::from_secs(60)),)
+                .await
+                .expect_err("future start must fail the immediate usability contract")
+                .kind(),
+            ErrorKind::RequestInvalid
+        );
+
+        let expired_after_generation =
+            operation.with_time_after_generation(timestamp("2030-01-01T00:05:00Z"));
+        assert_eq!(
+            expired_after_generation
+                .grant_credential(&Context::new(), &source, Some(Duration::from_secs(300)),)
+                .await
+                .expect_err("expired post-generation output must fail")
+                .kind(),
+            ErrorKind::RequestInvalid
+        );
+    }
+
+    #[tokio::test]
+    async fn core_granter_output_matches_presign_and_is_consumed_by_signer() {
+        let now = Timestamp::now();
+        let key = hash::base64_encode(VALID_ACCOUNT_KEY.as_bytes());
+        let source = Credential::with_shared_key("account", &key);
+        let operation = ServiceSasGranter::new(
+            ServiceSasConfig::new("account"),
+            ServiceSasGrant::for_blob(
+                "container",
+                "path/to/blob.txt",
+                ServiceSasBlobPermissions::READ,
+            ),
+        )
+        .with_time(now);
+        let output = Granter::new(
+            Context::new(),
+            StaticCredentialProvider::new_shared_key("account", &key),
+            operation,
+        )
+        .grant(Some(Duration::from_secs(300)))
+        .await
+        .expect("core granter must return a usable SAS credential");
+        let (token, expires_at) = output_parts(&output);
+        let token = token.to_string();
+
+        let signer = Signer::new(
+            Context::new(),
+            StaticCredentialProvider::new_sas_token_expires_at(&token, expires_at),
+            RequestSigner::new(),
+        );
+        let mut parts = http::Request::get(
+            "https://account.blob.core.windows.net/container/path/to/blob.txt?existing=%2F",
+        )
+        .body(())
+        .expect("request must build")
+        .into_parts()
+        .0;
+        signer
+            .sign(&mut parts, None)
+            .await
+            .expect("existing signer must consume granted credential");
+        assert_eq!(
+            parts.uri.to_string(),
+            format!(
+                "https://account.blob.core.windows.net/container/path/to/blob.txt?existing=%2F&{token}"
+            )
+        );
+        assert!(!parts.headers.contains_key(http::header::AUTHORIZATION));
+
+        let mut presigned =
+            http::Request::get("https://account.blob.core.windows.net/container/path/to/blob.txt")
+                .body(())
+                .expect("request must build")
+                .into_parts()
+                .0;
+        RequestSigner::new()
+            .with_time(now)
+            .with_service_sas_permissions("r")
+            .with_service_sas_protocol("https")
+            .sign_request(
+                &Context::new(),
+                &mut presigned,
+                Some(&source),
+                Some(Duration::from_secs(300)),
+            )
+            .await
+            .expect("compatibility presign must succeed");
+        assert_eq!(
+            presigned.uri.to_string(),
+            format!("https://account.blob.core.windows.net/container/path/to/blob.txt?{token}")
+        );
+    }
+
+    #[tokio::test]
+    async fn debug_and_errors_redact_grant_and_credential_material() {
+        let ip = SasIpRange::address(Ipv4Addr::new(198, 51, 100, 77));
+        let config = ServiceSasConfig::new("sensitiveaccount").with_ip(ip);
+        let grant = ServiceSasGrant::for_blob(
+            "sensitive-container",
+            "sensitive/path/blob.txt",
+            ServiceSasBlobPermissions::READ | ServiceSasBlobPermissions::WRITE,
+        );
+        let operation = ServiceSasGranter::new(config.clone(), grant.clone());
+        let resource = ServiceSasResource::Blob {
+            container: "sensitive-container".to_string(),
+            blob: "sensitive/path/blob.txt".to_string(),
+        };
+        let source = Credential::with_shared_key("sensitiveaccount", "raw-account-key-secret");
+        let sas = Credential::with_sas_token("sv=2020-12-06&sig=raw-sas-token-secret");
+
+        for (debug, secret) in [
+            (format!("{config:?}"), "sensitiveaccount"),
+            (format!("{grant:?}"), "sensitive-container"),
+            (format!("{resource:?}"), "sensitive/path"),
+            (format!("{ip:?}"), "198.51.100.77"),
+            (format!("{operation:?}"), "sensitiveaccount"),
+            (format!("{source:?}"), "raw-account-key-secret"),
+            (format!("{sas:?}"), "raw-sas-token-secret"),
+        ] {
+            assert!(!debug.contains(secret));
+        }
+        let credential_debug = format!("{source:?}\n{sas:?}");
+        for fragment in ["raw", "ret", "sv=", "sig="] {
+            assert!(!credential_debug.contains(fragment));
+        }
+
+        let err = operation
+            .grant_credential(&Context::new(), &source, Some(Duration::from_secs(60)))
+            .await
+            .expect_err("invalid key must fail without disclosure");
+        let debug = format!("{err:?}");
+        assert!(!debug.contains("raw-account-key-secret"));
+        assert!(!debug.contains("sensitiveaccount"));
+        assert!(!debug.contains("sensitive-container"));
+        assert!(!debug.contains("sensitive/path"));
+    }
 }
diff --git a/services/azure-storage/src/sign_request.rs b/services/azure-storage/src/sign_request.rs
index b9c843c..48387cd 100644
--- a/services/azure-storage/src/sign_request.rs
+++ b/services/azure-storage/src/sign_request.rs
@@ -24,7 +24,7 @@ use crate::user_delegation::{
 use http::request::Parts;
 use http::{HeaderValue, Uri, header};
 use log::debug;
-use percent_encoding::{percent_decode_str, percent_encode};
+use percent_encoding::percent_decode_str;
 use reqsign_core::hash::{base64_decode, base64_hmac_sha256};
 use reqsign_core::time::Timestamp;
 use reqsign_core::{
@@ -533,17 +533,7 @@ fn service_sas_resource(path: &str) -> Result<crate::service_sas::ServiceSasReso
 }
 
 fn append_query_pairs(uri: &Uri, pairs: &[(String, String)]) -> Result<Uri> {
-    let fragment = pairs
-        .iter()
-        .map(|(key, value)| {
-            format!(
-                "{}={}",
-                percent_encode(key.as_bytes(), &AZURE_QUERY_ENCODE_SET),
-                percent_encode(value.as_bytes(), &AZURE_QUERY_ENCODE_SET)
-            )
-        })
-        .collect::<Vec<_>>()
-        .join("&");
+    let fragment = crate::service_sas::encode_query_pairs(pairs);
     append_query_fragment(uri, &fragment)
 }
 
diff --git a/services/azure-storage/src/user_delegation_sas.rs b/services/azure-storage/src/user_delegation_sas.rs
index a81db32..b9a8048 100644
--- a/services/azure-storage/src/user_delegation_sas.rs
+++ b/services/azure-storage/src/user_delegation_sas.rs
@@ -133,7 +133,7 @@ impl BitOrAssign for UserDelegationSasPermissions {
     }
 }
 
-/// Protocol restriction embedded in a user delegation SAS.
+/// Protocol restriction embedded in an Azure Storage SAS.
 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
 pub enum SasProtocol {
     /// Require HTTPS for every request that uses the SAS.
@@ -144,7 +144,7 @@ pub enum SasProtocol {
 }
 
 impl SasProtocol {
-    fn as_str(self) -> &'static str {
+    pub(crate) fn as_str(self) -> &'static str {
         match self {
             Self::Https => "https",
             Self::HttpsAndHttp => "https,http",