Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for AAD auth, enabled by default #19

Merged
merged 4 commits into from
Aug 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Release Notes
All notable changes and release history of the "cosmos-db" module will be documented in this file.

## 1.19
* Changed to Entra Id authentication by default.
* Adds a `-enableMasterKeyAuth` flag to `Use-CosmosDbInternalFlag` which reverts auth to use master keys.

## 1.18
* Fixes a bug in commands like `Search-CosmosDbRecords` and `Get-AllCosmosDbRecords` which might run for long enough that their auth tokens expire and aren't refreshed. Auth tokens will now be refreshed every 10 min as these commands run.
* Adds a `-enableAuthHeaderReuse` flag to `Use-CosmosDbInternalFlag` which disables the 10 minute refresh period and forces auth header refreshes for every API call.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ Use-CosmosDbInternalFlag -EnableFiddlerDebugging $true
| EnableCaching | Enables/disables caching certain values like DB keys, partition ranges, etc. Improves performance of nearly all operations. | No - default is enabled |
| EnablePartitionKeyRangeSearches | **[Experimental]** Enables/disables filtering `Search` queries to relevant partition ranges instead of a full scan. Improves performance of `Search` commands. | No - default is disabled |
| EnableAuthHeaderReuse | Enables/disables reusing auth headers for commands which use continuation tokens, like `Search-CosmosDbRecords` or `Get-AllCosmosDbRecords`. | No - default is enabled |
| EnableMasterKeyAuth | Enables/disables using master key based authentication instead of Entra Id. | No - default is disabled |

## Error Handling

Expand Down
2 changes: 1 addition & 1 deletion cosmos-db/cosmos-db.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
# RootModule = ''

# Version number of this module.
ModuleVersion = '1.18'
ModuleVersion = '1.19'

# Supported PSEditions
# CompatiblePSEditions = @()
Expand Down
73 changes: 63 additions & 10 deletions cosmos-db/cosmos-db.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ $API_VERSION = "2018-12-31"
$AUTHORIZATION_HEADER_REFRESH_THRESHOLD = [System.TimeSpan]::FromMinutes(10);

$MASTER_KEY_CACHE = @{}
$AAD_TOKEN_CACHE = @{}
$SIGNATURE_HASH_CACHE = @{}
$PARTITION_KEY_RANGE_CACHE = @{}

Expand Down Expand Up @@ -57,13 +58,23 @@ Function Get-CacheValue([string]$key, [hashtable]$cache) {
return $null
}

Function Set-CacheValue([string]$key, $value, [hashtable]$cache, [int]$expirationHours) {
Function Set-CacheValue([string]$key, $value, [hashtable]$cache, [int]$expirationHours, [int]$expirationMinutes) {
$cache[$key] = @{
Expiration = [datetime]::UtcNow.AddHours($expirationHours);
Expiration = [datetime]::UtcNow.AddHours($expirationHours).AddMinutes($expirationMinutes);
Value = $value
}
}

Function RequireWriteableKey() {
# Entra Id auth does not support read only tokens (because it's just handled by RBAC).
# So if the user requested to be in read only mode, we have to manage it ourselves.

$readonlyEnabled = $env:COSMOS_DB_FLAG_ENABLE_READONLY_KEYS -eq 1
if ($readonlyEnabled) {
throw "Operation not allowed in readonly mode"
}
}

Function Get-Base64Masterkey([string]$ResourceGroup, [string]$Database, [string]$SubscriptionId) {
$readonly = $env:COSMOS_DB_FLAG_ENABLE_READONLY_KEYS -eq 1

Expand Down Expand Up @@ -99,6 +110,31 @@ Function Get-Base64MasterkeyWithoutCaching([string]$ResourceGroup, [string]$Data
$masterKey
}

Function Get-AADToken([string]$ResourceGroup, [string]$Database, [string]$SubscriptionId) {
$cacheKey = "aadtoken"
$cacheResult = Get-CacheValue -Key $cacheKey -Cache $AAD_TOKEN_CACHE
if ($cacheResult) {
return $cacheResult
}

$oauth = Get-AADTokenWithoutCaching
$token = $oauth.AccessToken

$expirationUtcTimestamp = $oauth.expires_on
$expiration = [System.DateTimeOffset]::FromUnixTimeSeconds($expirationUtcTimestamp).DateTime
$remainingTime = $expiration - [System.DateTime]::UtcNow
$cacheExpirationMinutes = ($remainingTime - $AUTHORIZATION_HEADER_REFRESH_THRESHOLD).Minutes

Set-CacheValue -Key $cacheKey -Value $token -Cache $AAD_TOKEN_CACHE -ExpirationMinutes $cacheExpirationMinutes

$token
}

# This is just to support testing caching with Get-AADToken and isn't meant to be used directly
Function Get-AADTokenWithoutCaching() {
az account get-access-token --resource "https://cosmos.azure.com" | ConvertFrom-Json
}

Function Get-Signature([string]$verb, [string]$resourceType, [string]$resourceUrl, [string]$now) {
$parts = @(
$verb.ToLower(),
Expand Down Expand Up @@ -128,19 +164,25 @@ Function Get-Base64EncryptedSignatureHash([string]$masterKey, [string]$signature
$base64Hash
}

Function Get-EncodedAuthString([string]$signatureHash) {
$authString = "type=master&ver=1.0&sig=$signatureHash"
Function Get-EncodedAuthString([string]$signatureHash, [string]$type) {
$authString = "type=$type&ver=1.0&sig=$signatureHash"
[uri]::EscapeDataString($authString)
}

Function Get-AuthorizationHeader([string]$ResourceGroup, [string]$SubscriptionId, [string]$Database, [string]$verb, [string]$resourceType, [string]$resourceUrl, [string]$now) {
$masterKey = Get-Base64Masterkey -ResourceGroup $ResourceGroup -Database $Database -SubscriptionId $SubscriptionId
if ($env:COSMOS_DB_FLAG_ENABLE_MASTER_KEY_AUTH -eq 1) {
$masterKey = Get-Base64Masterkey -ResourceGroup $ResourceGroup -Database $Database -SubscriptionId $SubscriptionId

$signature = Get-Signature -verb $verb -resourceType $resourceType -resourceUrl $resourceUrl -now $now
$signature = Get-Signature -verb $verb -resourceType $resourceType -resourceUrl $resourceUrl -now $now

$signatureHash = Get-Base64EncryptedSignatureHash -masterKey $masterKey -signature $signature

return Get-EncodedAuthString -signatureHash $signatureHash -type "master"
}

$signatureHash = Get-Base64EncryptedSignatureHash -masterKey $masterKey -signature $signature
$token = Get-AADToken

Get-EncodedAuthString -signatureHash $signatureHash
return Get-EncodedAuthString -signatureHash $token -type "aad"
}

Function Get-CommonHeaders([string]$now, [string]$encodedAuthString, [string]$contentType = "application/json", [bool]$isQuery = $false, [string]$PartitionKey = $null, [string]$Etag = $null) {
Expand Down Expand Up @@ -268,7 +310,7 @@ Function Invoke-CosmosDbApiRequestWithContinuation([string]$verb, [string]$url,
$headers["x-ms-continuation"] = $continuationToken

$authHeaderReuseDisabled = $env:COSMOS_DB_FLAG_ENABLE_AUTH_HEADER_REUSE -eq 0
$authHeaderExpired = [System.DateTime]::Parse($authHeaders.now) + $AUTHORIZATION_HEADER_REFRESH_THRESHOLD -lt [System.DateTime]::UtcNow
$authHeaderExpired = [System.DateTimeOffset]::Parse($authHeaders.now) + $AUTHORIZATION_HEADER_REFRESH_THRESHOLD -lt [System.DateTime]::UtcNow

if ($authHeaderReuseDisabled -or $authHeaderExpired) {
$authHeaders = Invoke-Command -ScriptBlock $refreshAuthHeaders
Expand Down Expand Up @@ -746,6 +788,8 @@ Function New-CosmosDbRecord {
)

begin {
RequireWriteableKey

$baseUrl = Get-BaseDatabaseUrl $Database
$collectionsUrl = Get-CollectionsUrl $Container $Collection
$docsUrl = "$collectionsUrl/$DOCS_TYPE"
Expand Down Expand Up @@ -833,6 +877,8 @@ Function Update-CosmosDbRecord {
)

begin {
RequireWriteableKey

$baseUrl = Get-BaseDatabaseUrl $Database
}
process {
Expand Down Expand Up @@ -929,6 +975,8 @@ Function Remove-CosmosDbRecord {
)

begin {
RequireWriteableKey

$baseUrl = Get-BaseDatabaseUrl $Database
}
process {
Expand Down Expand Up @@ -1005,7 +1053,8 @@ Function Use-CosmosDbInternalFlag
$enableFiddlerDebugging = $null,
$enableCaching = $null,
$enablePartitionKeyRangeSearches = $null,
$enableAuthHeaderReuse = $null
$enableAuthHeaderReuse = $null,
$enableMasterKeyAuth = $null
) {
if ($null -ne $enableFiddlerDebugging) {
$env:AZURE_CLI_DISABLE_CONNECTION_VERIFICATION = if ($enableFiddlerDebugging) { 1 } else { 0 }
Expand All @@ -1022,6 +1071,10 @@ Function Use-CosmosDbInternalFlag
if ($null -ne $enableAuthHeaderReuse) {
$env:COSMOS_DB_FLAG_ENABLE_AUTH_HEADER_REUSE = if ($enableAuthHeaderReuse) { 1 } else { 0 }
}

if ($null -ne $enableMasterKeyAuth) {
$env:COSMOS_DB_FLAG_ENABLE_MASTER_KEY_AUTH = if ($enableMasterKeyAuth) { 1 } else { 0 }
}
}

Function Use-CosmosDbReadonlyKeys
Expand Down
67 changes: 67 additions & 0 deletions tests/Get-AadToken.Tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
Get-Module cosmos-db | Remove-Module -Force
Import-Module $PSScriptRoot\..\cosmos-db\cosmos-db.psm1 -Force

InModuleScope cosmos-db {
Describe "Get-AadToken" {
BeforeAll {
$MOCK_TOKEN = @{
accessToken = "MOCK_TOKEN"
expires_on = [System.DateTimeOffset]::UtcNow.AddHours(1).ToUnixTimeSeconds()
}

Mock Get-AadTokenWithoutCaching {
return $MOCK_TOKEN
}
}

BeforeEach {
# This is defined in the main module
$AAD_TOKEN_CACHE = @{}
}

It "Only calls the core logic once with caching enabled" {
Use-CosmosDbInternalFlag -EnableCaching $true

$key = Get-AadToken
$key | Should -Be $MOCK_TOKEN.accessToken | Out-Null

$key = Get-AadToken
$key | Should -Be $MOCK_TOKEN.accessToken | Out-Null

Assert-MockCalled Get-AadTokenWithoutCaching -Times 1 -Exactly
}

It "Calls the core logic for each call with caching disabled" {
Use-CosmosDbInternalFlag -EnableCaching $false

$key1 = Get-AadToken
$key1 | Should -Be $MOCK_TOKEN.accessToken | Out-Null

$key2 = Get-AadToken
$key2 | Should -Be $MOCK_TOKEN.accessToken | Out-Null

Assert-MockCalled Get-AadTokenWithoutCaching -Times 2 -Exactly
}

It "Respects token expiration" {
Use-CosmosDbInternalFlag -EnableCaching $true

$MOCK_TOKEN = @{
accessToken = "MOCK_TOKEN"
expires_on = [System.DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
}

Mock Get-AadTokenWithoutCaching {
return $MOCK_TOKEN
}

$key1 = Get-AadToken
$key1 | Should -Be $MOCK_TOKEN.accessToken | Out-Null

$key2 = Get-AadToken
$key2 | Should -Be $MOCK_TOKEN.accessToken | Out-Null

Assert-MockCalled Get-AadTokenWithoutCaching -Times 2 -Exactly
}
}
}
31 changes: 26 additions & 5 deletions tests/Get-AuthorizationHeader.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ InModuleScope cosmos-db {
$MOCK_RESOURCE_TYPE = "MOCK_RESOURCE_TYPE"
$MOCK_VERB = "MOCK_VERB"
$MOCK_NOW = "MOCK_NOW"
$MOCK_AAD_TOKEN = "MOCK_AAD_TOKEN"

$MOCK_MASTER_KEY_BYTES = [System.Text.Encoding]::UTF8.GetBytes('gVkYp3s6v9y$B&E)H@MbQeThWmZq4t7w')

Expand All @@ -25,23 +26,43 @@ InModuleScope cosmos-db {

[System.Convert]::ToBase64String($MOCK_MASTER_KEY_BYTES)
}

Mock Get-AADToken {
return $MOCK_AAD_TOKEN
}
}

AfterEach {
$env:COSMOS_DB_FLAG_ENABLE_MASTER_KEY_AUTH = $null
}

It "Returns the correct signature hashed with the master key" {
$result = Get-AuthorizationHeader -ResourceGroup $MOCK_RG -SubscriptionId $MOCK_SUB -Database $MOCK_DB -Verb $MOCK_VERB -ResourceType $MOCK_RESOURCE_TYPE -ResourceUrl $MOCK_RESOURCE_URL -Now $MOCK_NOW
It "Returns the correct signature hashed with the master key" {
Use-CosmosDbInternalFlag -enableMasterKeyAuth $true

$result = Get-AuthorizationHeader -ResourceGroup $MOCK_RG -SubscriptionId $MOCK_SUB -Database $MOCK_DB -Verb $MOCK_VERB -ResourceType $MOCK_RESOURCE_TYPE -ResourceUrl $MOCK_RESOURCE_URL -Now $MOCK_NOW

$expectedSignature = "$($MOCK_VERB.ToLower())`n$($MOCK_RESOURCE_TYPE.ToLower())`n$MOCK_RESOURCE_URL`n$($MOCK_NOW.ToLower())`n`n"

$hasher = New-Object System.Security.Cryptography.HMACSHA256 -Property @{ Key = $MOCK_MASTER_KEY_BYTES }
$sigBinary=[System.Text.Encoding]::UTF8.GetBytes($expectedSignature)
$hashBytes=$hasher.ComputeHash($sigBinary)
$expectedBase64Hash=[System.Convert]::ToBase64String($hashBytes)
$sigBinary = [System.Text.Encoding]::UTF8.GetBytes($expectedSignature)
$hashBytes = $hasher.ComputeHash($sigBinary)
$expectedBase64Hash = [System.Convert]::ToBase64String($hashBytes)

$expectedHeader = [uri]::EscapeDataString("type=master&ver=1.0&sig=$expectedBase64Hash")

$result | Should -Be $expectedHeader

Assert-MockCalled Get-Base64Masterkey -Times 1
}

It "Returns the correct signature with for entra id auth" {
$result = Get-AuthorizationHeader -ResourceGroup $MOCK_RG -SubscriptionId $MOCK_SUB -Database $MOCK_DB -Verb $MOCK_VERB -ResourceType $MOCK_RESOURCE_TYPE -ResourceUrl $MOCK_RESOURCE_URL -Now $MOCK_NOW

$expectedHeader = [uri]::EscapeDataString("type=aad&ver=1.0&sig=$MOCK_AAD_TOKEN")

$result | Should -Be $expectedHeader

Assert-MockCalled Get-AADToken -Times 1
}
}
}
4 changes: 4 additions & 0 deletions tests/Get-Base64Masterkey.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ InModuleScope cosmos-db {
}
}

AfterAll {
$env:COSMOS_DB_FLAG_ENABLE_READONLY_KEYS = $null
}

BeforeEach {
# This is defined in the main module
$MASTER_KEY_CACHE = @{}
Expand Down
4 changes: 4 additions & 0 deletions tests/Get-CosmosDbRecordContent.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ InModuleScope cosmos-db {
. $PSScriptRoot\Utils.ps1
}

AfterAll {
$env:COSMOS_DB_FLAG_ENABLE_READONLY_KEYS = $null
}

It "Returns the Content of a successful response" {
$content = @{
Key1 = "Value1";
Expand Down
1 change: 0 additions & 1 deletion tests/Invoke-CosmosDbApiRequestWithContinuation.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,6 @@ InModuleScope cosmos-db {
}

It "Does not refresh auth headers before age threshold" {
# Force each call to refresh
$AUTHORIZATION_HEADER_REFRESH_THRESHOLD = [System.TimeSpan]::FromHours(1)

$continuationTokens = @($null, "100", "200", "300")
Expand Down
Loading