|
| 1 | +function New-CIPPAzServiceSAS { |
| 2 | + [CmdletBinding()] param( |
| 3 | + [Parameter(Mandatory = $true)] [string] $AccountName, |
| 4 | + [Parameter(Mandatory = $true)] [string] $AccountKey, |
| 5 | + [Parameter(Mandatory = $true)] [ValidateSet('blob', 'queue', 'file', 'table')] [string] $Service, |
| 6 | + [Parameter(Mandatory = $true)] [string] $ResourcePath, |
| 7 | + [Parameter(Mandatory = $true)] [string] $Permissions, |
| 8 | + [Parameter(Mandatory = $false)] [DateTime] $StartTime, |
| 9 | + [Parameter(Mandatory = $true)] [DateTime] $ExpiryTime, |
| 10 | + [Parameter(Mandatory = $false)] [ValidateSet('http', 'https', 'https,http')] [string] $Protocol = 'https', |
| 11 | + [Parameter(Mandatory = $false)] [string] $IP, |
| 12 | + [Parameter(Mandatory = $false)] [string] $SignedIdentifier, |
| 13 | + [Parameter(Mandatory = $false)] [string] $Version = '2022-11-02', |
| 14 | + [Parameter(Mandatory = $false)] [ValidateSet('b', 'c', 'd', 'bv', 'bs', 'f', 's')] [string] $SignedResource, |
| 15 | + [Parameter(Mandatory = $false)] [int] $DirectoryDepth, |
| 16 | + [Parameter(Mandatory = $false)] [string] $SnapshotTime, |
| 17 | + # Optional response header overrides (Blob/Files) |
| 18 | + [Parameter(Mandatory = $false)] [string] $CacheControl, |
| 19 | + [Parameter(Mandatory = $false)] [string] $ContentDisposition, |
| 20 | + [Parameter(Mandatory = $false)] [string] $ContentEncoding, |
| 21 | + [Parameter(Mandatory = $false)] [string] $ContentLanguage, |
| 22 | + [Parameter(Mandatory = $false)] [string] $ContentType, |
| 23 | + # Optional encryption scope (Blob, 2020-12-06+) |
| 24 | + [Parameter(Mandatory = $false)] [string] $EncryptionScope, |
| 25 | + # Optional connection string for endpoint/emulator support |
| 26 | + [Parameter(Mandatory = $false)] [string] $ConnectionString = $env:AzureWebJobsStorage |
| 27 | + ) |
| 28 | + |
| 29 | + # Local helpers: canonicalized resource and signature (standalone) |
| 30 | + function _GetCanonicalizedResource { |
| 31 | + param( |
| 32 | + [Parameter(Mandatory = $true)][string] $AccountName, |
| 33 | + [Parameter(Mandatory = $true)][ValidateSet('blob', 'queue', 'file', 'table')] [string] $Service, |
| 34 | + [Parameter(Mandatory = $true)][string] $ResourcePath |
| 35 | + ) |
| 36 | + $decodedPath = [System.Web.HttpUtility]::UrlDecode(($ResourcePath.TrimStart('/'))) |
| 37 | + switch ($Service) { |
| 38 | + 'blob' { return "/blob/$AccountName/$decodedPath" } |
| 39 | + 'queue' { return "/queue/$AccountName/$decodedPath" } |
| 40 | + 'file' { return "/file/$AccountName/$decodedPath" } |
| 41 | + 'table' { return "/table/$AccountName/$decodedPath" } |
| 42 | + } |
| 43 | + } |
| 44 | + |
| 45 | + function _NewSharedKeySignature { |
| 46 | + param( |
| 47 | + [Parameter(Mandatory = $true)][string] $AccountKey, |
| 48 | + [Parameter(Mandatory = $true)][string] $StringToSign |
| 49 | + ) |
| 50 | + $keyBytes = [Convert]::FromBase64String($AccountKey) |
| 51 | + $hmac = [System.Security.Cryptography.HMACSHA256]::new($keyBytes) |
| 52 | + try { |
| 53 | + $bytes = [System.Text.Encoding]::UTF8.GetBytes($StringToSign) |
| 54 | + $sig = $hmac.ComputeHash($bytes) |
| 55 | + return [Convert]::ToBase64String($sig) |
| 56 | + } finally { $hmac.Dispose() } |
| 57 | + } |
| 58 | + |
| 59 | + # Parse connection string for emulator/provided endpoints |
| 60 | + $ProvidedEndpoint = $null |
| 61 | + $ProvidedPath = $null |
| 62 | + $EmulatorHost = $null |
| 63 | + $EndpointSuffix = 'core.windows.net' |
| 64 | + |
| 65 | + if ($ConnectionString) { |
| 66 | + $conn = @{} |
| 67 | + foreach ($part in ($ConnectionString -split ';')) { |
| 68 | + $p = $part.Trim() |
| 69 | + if ($p -and $p -match '^(.+?)=(.+)$') { $conn[$matches[1]] = $matches[2] } |
| 70 | + } |
| 71 | + if ($conn['EndpointSuffix']) { $EndpointSuffix = $conn['EndpointSuffix'] } |
| 72 | + |
| 73 | + $ServiceCapitalized = [char]::ToUpper($Service[0]) + $Service.Substring(1) |
| 74 | + $EndpointKey = "${ServiceCapitalized}Endpoint" |
| 75 | + if ($conn[$EndpointKey]) { |
| 76 | + $ProvidedEndpoint = $conn[$EndpointKey] |
| 77 | + $ep = [Uri]::new($ProvidedEndpoint) |
| 78 | + $Protocol = $ep.Scheme |
| 79 | + $EmulatorHost = $ep.Host |
| 80 | + if ($ep.Port -ne -1) { $EmulatorHost = "$($ep.Host):$($ep.Port)" } |
| 81 | + $ProvidedPath = $ep.AbsolutePath.TrimEnd('/') |
| 82 | + } elseif ($conn['UseDevelopmentStorage'] -eq 'true') { |
| 83 | + # Emulator defaults |
| 84 | + if (-not $AccountName) { $AccountName = 'devstoreaccount1' } |
| 85 | + if (-not $AccountKey) { $AccountKey = 'Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==' } |
| 86 | + $Protocol = 'http' |
| 87 | + $ports = @{ blob = 10000; queue = 10001; table = 10002 } |
| 88 | + $EmulatorHost = "127.0.0.1:$($ports[$Service])" |
| 89 | + } |
| 90 | + } |
| 91 | + |
| 92 | + # Build the resource URI |
| 93 | + if ($ResourcePath.StartsWith('/')) { $ResourcePath = $ResourcePath.TrimStart('/') } |
| 94 | + $UriBuilder = [System.UriBuilder]::new() |
| 95 | + $UriBuilder.Scheme = $Protocol |
| 96 | + |
| 97 | + if ($ProvidedEndpoint) { |
| 98 | + # Use provided endpoint + its base path |
| 99 | + if ($EmulatorHost -match '^(.+?):(\d+)$') { $UriBuilder.Host = $matches[1]; $UriBuilder.Port = [int]$matches[2] } |
| 100 | + else { $UriBuilder.Host = $EmulatorHost } |
| 101 | + $UriBuilder.Path = ("$ProvidedPath/$ResourcePath").Replace('//', '/') |
| 102 | + } elseif ($EmulatorHost) { |
| 103 | + # Emulator: include account name in path |
| 104 | + if ($EmulatorHost -match '^(.+?):(\d+)$') { $UriBuilder.Host = $matches[1]; $UriBuilder.Port = [int]$matches[2] } |
| 105 | + else { $UriBuilder.Host = $EmulatorHost } |
| 106 | + $UriBuilder.Path = "$AccountName/$ResourcePath" |
| 107 | + } else { |
| 108 | + # Standard Azure endpoint |
| 109 | + $UriBuilder.Host = "$AccountName.$Service.$EndpointSuffix" |
| 110 | + $UriBuilder.Path = $ResourcePath |
| 111 | + } |
| 112 | + $uri = $UriBuilder.Uri |
| 113 | + |
| 114 | + # Canonicalized resource for SAS string-to-sign (service-name style, 2015-02-21+) |
| 115 | + $canonicalizedResource = _GetCanonicalizedResource -AccountName $AccountName -Service $Service -ResourcePath $ResourcePath |
| 116 | + |
| 117 | + # Time formatting per SAS spec (ISO 8601 UTC) |
| 118 | + function _FormatSasTime($dt) { |
| 119 | + if ($null -eq $dt) { return '' } |
| 120 | + if ($dt -is [string]) { |
| 121 | + if ([string]::IsNullOrWhiteSpace($dt)) { return '' } |
| 122 | + $parsed = [DateTime]::Parse($dt, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal) |
| 123 | + $utc = $parsed.ToUniversalTime() |
| 124 | + return $utc.ToString('yyyy-MM-ddTHH:mm:ssZ') |
| 125 | + } |
| 126 | + if ($dt -is [DateTime]) { |
| 127 | + $utc = ([DateTime]$dt).ToUniversalTime() |
| 128 | + return $utc.ToString('yyyy-MM-ddTHH:mm:ssZ') |
| 129 | + } |
| 130 | + return '' |
| 131 | + } |
| 132 | + |
| 133 | + $st = _FormatSasTime $StartTime |
| 134 | + $se = _FormatSasTime $ExpiryTime |
| 135 | + if ([string]::IsNullOrEmpty($se)) { throw 'ExpiryTime is required for SAS generation.' } |
| 136 | + |
| 137 | + # Assemble query parameters (service-specific) |
| 138 | + $q = @{} |
| 139 | + $q['sp'] = $Permissions |
| 140 | + if ($st) { $q['st'] = $st } |
| 141 | + $q['se'] = $se |
| 142 | + if ($IP) { $q['sip'] = $IP } |
| 143 | + if ($Protocol) { $q['spr'] = $Protocol } |
| 144 | + if ($Version) { $q['sv'] = $Version } |
| 145 | + if ($SignedIdentifier) { $q['si'] = $SignedIdentifier } |
| 146 | + |
| 147 | + # Blob/Files response headers overrides |
| 148 | + if ($CacheControl) { $q['rscc'] = $CacheControl } |
| 149 | + if ($ContentDisposition) { $q['rscd'] = $ContentDisposition } |
| 150 | + if ($ContentEncoding) { $q['rsce'] = $ContentEncoding } |
| 151 | + if ($ContentLanguage) { $q['rscl'] = $ContentLanguage } |
| 152 | + if ($ContentType) { $q['rsct'] = $ContentType } |
| 153 | + |
| 154 | + # Resource-type specifics |
| 155 | + $includeEncryptionScope = $false |
| 156 | + if ($Service -eq 'blob') { |
| 157 | + if (-not $SignedResource) { throw 'SignedResource (sr) is required for blob SAS: use b, c, d, bv, or bs.' } |
| 158 | + $q['sr'] = $SignedResource |
| 159 | + # Blob snapshot time uses the 'snapshot' parameter when applicable |
| 160 | + if ($SnapshotTime) { $q['snapshot'] = $SnapshotTime } |
| 161 | + if ($SignedResource -eq 'd') { |
| 162 | + if ($null -eq $DirectoryDepth) { throw 'DirectoryDepth (sdd) is required when sr=d (Data Lake Hierarchical Namespace).' } |
| 163 | + $q['sdd'] = [string]$DirectoryDepth |
| 164 | + } |
| 165 | + if ($EncryptionScope -and $Version -ge '2020-12-06') { |
| 166 | + $q['ses'] = $EncryptionScope |
| 167 | + $includeEncryptionScope = $true |
| 168 | + } |
| 169 | + } elseif ($Service -eq 'file') { |
| 170 | + if (-not $SignedResource) { throw 'SignedResource (sr) is required for file SAS: use f or s.' } |
| 171 | + $q['sr'] = $SignedResource |
| 172 | + if ($SnapshotTime) { $q['sst'] = $SnapshotTime } |
| 173 | + } elseif ($Service -eq 'table') { |
| 174 | + # Table SAS may include ranges (spk/srk/epk/erk), omitted here unless future parameters are added |
| 175 | + # Table also uses tn (table name) in query, but canonicalizedResource already includes table name |
| 176 | + # We rely on canonicalizedResource and omit tn unless specified by callers via ResourcePath |
| 177 | + } elseif ($Service -eq 'queue') { |
| 178 | + # No sr for queue |
| 179 | + } |
| 180 | + |
| 181 | + # Construct string-to-sign based on service and version |
| 182 | + $StringToSign = $null |
| 183 | + if ($Service -eq 'blob') { |
| 184 | + # Version 2018-11-09 and later (optionally 2020-12-06 with encryption scope) |
| 185 | + $fields = @( |
| 186 | + $q['sp'], |
| 187 | + ($st ?? ''), |
| 188 | + $q['se'], |
| 189 | + $canonicalizedResource, |
| 190 | + ($q.ContainsKey('si') ? $q['si'] : ''), |
| 191 | + ($q.ContainsKey('sip') ? $q['sip'] : ''), |
| 192 | + ($q.ContainsKey('spr') ? $q['spr'] : ''), |
| 193 | + ($q.ContainsKey('sv') ? $q['sv'] : ''), |
| 194 | + $q['sr'], |
| 195 | + ($q.ContainsKey('snapshot') ? $q['snapshot'] : ''), |
| 196 | + ($includeEncryptionScope ? $q['ses'] : ''), |
| 197 | + ($q.ContainsKey('rscc') ? $q['rscc'] : ''), |
| 198 | + ($q.ContainsKey('rscd') ? $q['rscd'] : ''), |
| 199 | + ($q.ContainsKey('rsce') ? $q['rsce'] : ''), |
| 200 | + ($q.ContainsKey('rscl') ? $q['rscl'] : ''), |
| 201 | + ($q.ContainsKey('rsct') ? $q['rsct'] : '') |
| 202 | + ) |
| 203 | + $StringToSign = ($fields -join "`n") |
| 204 | + } elseif ($Service -eq 'file') { |
| 205 | + # Use 2015-04-05+ format (no signedResource in string until 2018-11-09; we include response headers) |
| 206 | + $fields = @( |
| 207 | + $q['sp'], |
| 208 | + ($st ?? ''), |
| 209 | + $q['se'], |
| 210 | + $canonicalizedResource, |
| 211 | + ($q.ContainsKey('si') ? $q['si'] : ''), |
| 212 | + ($q.ContainsKey('sip') ? $q['sip'] : ''), |
| 213 | + ($q.ContainsKey('spr') ? $q['spr'] : ''), |
| 214 | + ($q.ContainsKey('sv') ? $q['sv'] : ''), |
| 215 | + ($q.ContainsKey('rscc') ? $q['rscc'] : ''), |
| 216 | + ($q.ContainsKey('rscd') ? $q['rscd'] : ''), |
| 217 | + ($q.ContainsKey('rsce') ? $q['rsce'] : ''), |
| 218 | + ($q.ContainsKey('rscl') ? $q['rscl'] : ''), |
| 219 | + ($q.ContainsKey('rsct') ? $q['rsct'] : '') |
| 220 | + ) |
| 221 | + $StringToSign = ($fields -join "`n") |
| 222 | + } elseif ($Service -eq 'queue') { |
| 223 | + # Version 2015-04-05 and later |
| 224 | + $fields = @( |
| 225 | + $q['sp'], |
| 226 | + ($st ?? ''), |
| 227 | + $q['se'], |
| 228 | + $canonicalizedResource, |
| 229 | + ($q.ContainsKey('si') ? $q['si'] : ''), |
| 230 | + ($q.ContainsKey('sip') ? $q['sip'] : ''), |
| 231 | + ($q.ContainsKey('spr') ? $q['spr'] : ''), |
| 232 | + ($q.ContainsKey('sv') ? $q['sv'] : '') |
| 233 | + ) |
| 234 | + $StringToSign = ($fields -join "`n") |
| 235 | + } elseif ($Service -eq 'table') { |
| 236 | + # Version 2015-04-05 and later |
| 237 | + $fields = @( |
| 238 | + $q['sp'], |
| 239 | + ($st ?? ''), |
| 240 | + $q['se'], |
| 241 | + $canonicalizedResource, |
| 242 | + ($q.ContainsKey('si') ? $q['si'] : ''), |
| 243 | + ($q.ContainsKey('sip') ? $q['sip'] : ''), |
| 244 | + ($q.ContainsKey('spr') ? $q['spr'] : ''), |
| 245 | + ($q.ContainsKey('sv') ? $q['sv'] : ''), |
| 246 | + '', # startingPartitionKey |
| 247 | + '', # startingRowKey |
| 248 | + '', # endingPartitionKey |
| 249 | + '' # endingRowKey |
| 250 | + ) |
| 251 | + $StringToSign = ($fields -join "`n") |
| 252 | + } |
| 253 | + |
| 254 | + # Generate signature using account key (HMAC-SHA256 over UTF-8 string-to-sign) |
| 255 | + try { |
| 256 | + $SignatureBase64 = _NewSharedKeySignature -AccountKey $AccountKey -StringToSign $StringToSign |
| 257 | + } catch { |
| 258 | + throw "Failed to create SAS signature: $($_.Exception.Message)" |
| 259 | + } |
| 260 | + |
| 261 | + # Store signature; will be URL-encoded when assembling query |
| 262 | + $q['sig'] = $SignatureBase64 |
| 263 | + |
| 264 | + # Compose ordered query for readability (common fields first) |
| 265 | + $orderedKeys = @('sp', 'st', 'se', 'sip', 'spr', 'sv', 'sr', 'si', 'snapshot', 'ses', 'sdd', 'rscc', 'rscd', 'rsce', 'rscl', 'rsct', 'sig') |
| 266 | + $parts = @() |
| 267 | + foreach ($k in $orderedKeys) { |
| 268 | + if ($q.ContainsKey($k) -and -not [string]::IsNullOrEmpty($q[$k])) { |
| 269 | + $parts += ("$k=" + [System.Net.WebUtility]::UrlEncode($q[$k])) |
| 270 | + } |
| 271 | + } |
| 272 | + # Include any remaining keys |
| 273 | + foreach ($k in $q.Keys) { |
| 274 | + if ($orderedKeys -notcontains $k) { |
| 275 | + $parts += ("$k=" + [System.Net.WebUtility]::UrlEncode($q[$k])) |
| 276 | + } |
| 277 | + } |
| 278 | + |
| 279 | + $token = '?' + ($parts -join '&') |
| 280 | + |
| 281 | + # Return structured output for debugging/usage |
| 282 | + [PSCustomObject]@{ |
| 283 | + Token = $token |
| 284 | + Query = $q |
| 285 | + CanonicalizedResource = $canonicalizedResource |
| 286 | + StringToSign = $StringToSign |
| 287 | + Version = $Version |
| 288 | + Service = $Service |
| 289 | + ResourceUri = $uri.AbsoluteUri |
| 290 | + } |
| 291 | +} |
0 commit comments