Skip to content

Commit

Permalink
BREAKING CHANGE - CORE 'Add Azure DevOps Managed Identity Support' (#2)
Browse files Browse the repository at this point in the history
* Adding Initial Class

* Creating ManagedIdentityToken class.
Expanding ManagedIdentityClass
Updated Invoke-AZDevOpsAPIRestMethod with ManagedIdentity Handeler

* Refactoring 004.AzManagedIdentity into Functions
Added APIRateLimit Enum

* Renaming DataResources
Updating AzureDevOpsDsc.Common.psd1
Initial ompleted Managed Identity Cmdlets.

* Adding Initial Tests

* Fixing Documentation
Adding Unit Tests

* Moving Tests
Completed ManagedIdentityToken Test

* Completed APIRateLimit Unit Testing

* Bug Fixes on Azure VM to get Managed Identity Working

* Updating Tests

* Fixing Tests

* Test Updates

* Updating Paths

* Update Paths

* Bug fixes with build and testing process

* Fixing Headers

* Bug Fixes with the Token

* Bug Fixes

* More bug fixes

* Fixing Bugs

* Updated Documentation and ChangeLog to bring into line with guidelines.

---------

Co-authored-by: Michael Zanatta <[email protected]>
  • Loading branch information
ZanattaMichael and Michael Zanatta authored Jan 29, 2024
1 parent dbc4607 commit afecb3e
Show file tree
Hide file tree
Showing 25 changed files with 1,098 additions and 15 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- AzureDevOpsDsc
- Azure Managed Identity supporting classes. These classes are used by `AzureDevOpsDsc.Common`.
- Updated pipeline files to support change of default branch to main.
- Added GitHub issue templates and pull request template
([issue #1](https://github.com/dsccommunity/AzureDevOpsDsc/issues/1))
Expand All @@ -29,7 +30,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
file `Home.md` will be updated with the correct module version on each
publish to gallery (including preview).
- AzureDevOpsDsc.Common
- Managed Identity has been added to the system. This feature can be used before invoking Invoke-DSCResource. With New-AzManagedIdentity, the bearer token is automatically authenticated, retrieved, and managed. It’s worth noting that bearer tokens take precedence over basic tokens. When using Invoke-AzDevOpsApiRestMethod, the token is automatically interpolated as required.
- Added 'wrapper' functionality around the [Azure DevOps REST API](https://docs.microsoft.com/en-us/rest/api/azure/devops/)
- Added Supporting Functions for Azure Managed Identity.

### Changed

Expand Down
16 changes: 16 additions & 0 deletions source/Classes/000.ManagedIdentityDataResources.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
data AzManagedIdentityLocalizedData {

ConvertFrom-StringData @'
Global_AzureDevOps_Resource_Id=499b84ac-1321-427f-aa17-267ca6975798
Global_Url_Azure_Instance_Metadata_Url=http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource={0}
Global_API_Azure_DevOps_Version=api-version=6.0
Global_Url_AZDO_Project=https://dev.azure.com/{0}/_apis/projects
Error_ManagedIdentity_RestApiCallFailed=Error. Failed to call the Azure Instance Metadata Service. Please ensure that the Azure Instance Metadata Service is available. Error Details: {0}
Error_Azure_Instance_Metadata_Service_Missing_Token=Error. Access token not returned from Azure Instance Metadata Service. Please ensure that the Azure Instance Metadata Service is available.
Error_Azure_API_Call_Generic=Error. Failed to call the Azure DevOps API. Details: {0}
Error_Azure_Get_AzManagedIdentity_Invalid_Caller=Error. Get-AzManagedIdentity can only be called from New-AzManagedIdentity or Update-AzManagedIdentity.
'@

}

New-Variable -Name AzManagedIdentityLocalizedData -Value $AzManagedIdentityLocalizedData -Option ReadOnly -Scope Global -Force
1 change: 1 addition & 0 deletions source/Classes/003.AzDevOpsDscResourceBase.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ class AzDevOpsDscResourceBase : AzDevOpsApiDscResourceBase

[DscProperty()]
[Alias('PersonalAccessToken')]
[Alias('AccessToken')]
[System.String]
$Pat

Expand Down
106 changes: 106 additions & 0 deletions source/Classes/004.ManagedIdentityToken.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@


Class ManagedIdentityToken {

[SecureString]$access_token
[DateTime]$expires_on
[Int]$expires_in
[String]$resource
[String]$token_type
hidden [bool]$linux = $IsLinux

# Constructor
ManagedIdentityToken([PSCustomObject]$ManagedIdentityTokenObj) {

# Validate that ManagedIdentityTokenObj is a HashTable and Contains the correct keys
if (-not $this.isValid($ManagedIdentityTokenObj)) { throw "The ManagedIdentityTokenObj is not valid." }

$epochStart = [datetime]::new(1970, 1, 1, 0, 0, 0, [DateTimeKind]::Utc)

# Set the properties of the class
$this.access_token = $ManagedIdentityTokenObj.access_token | ConvertTo-SecureString -AsPlainText -Force
$this.expires_on = $epochStart.AddSeconds($ManagedIdentityTokenObj.expires_on)
$this.expires_in = $ManagedIdentityTokenObj.expires_in
$this.resource = $ManagedIdentityTokenObj.resource
$this.token_type = $ManagedIdentityTokenObj.token_type

}

# Function to validate the ManagedIdentityTokenObj
Hidden [Bool]isValid($ManagedIdentityTokenObj) {

# Assuming these are the keys we expect in the hashtable
$expectedKeys = @('access_token', 'expires_on', 'expires_in', 'resource', 'token_type')

# Check if all expected keys exist in the hashtable
foreach ($key in $expectedKeys) {
if (-not $ManagedIdentityTokenObj."$key") {
Write-Verbose "[ManagedIdentityToken] The hashtable does not contain the expected property: $key"
return $false
}
}

# If all checks pass, return true
Write-Verbose "[ManagedIdentityToken] The hashtable is valid and contains all the expected keys."
return $true
}

# Function to convert a SecureString to a String
hidden [String]ConvertFromSecureString([SecureString]$SecureString) {
# Convert a SecureString to a String
$BSTR = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString)
$String = ($this.linux) ? [System.Runtime.InteropServices.Marshal]::PtrToStringUni($BSTR) :
[System.Runtime.InteropServices.Marshal]::PtrToStringAuto($BSTR)
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($BSTR)
return $String
}

# Function to test the call stack
hidden [Bool]TestCallStack([String]$name) {

$CallStack = Get-PSCallStack

# Check if any of the callers in the call stack is Invoke-DSCResource
foreach ($stackFrame in $callStack) {
if ($stackFrame.Command -eq $name) {
Write-Verbose "[ManagedIdentityToken] The calling function is $name."
return $true
}
}

return $false

}

[Bool]isExpired() {
# Remove 10 seconds from the expires_on time to account for clock skew.
if ($this.expires_on.AddSeconds(-10) -lt (Get-Date)) { return $true }
return $false
}

# Return the access token
[String] Get() {

# Prevent Execution and Writing to Files and Pipeline Variables.

# Token can only be called within Invoke-AzDevOpsApiRestMethod. Test to see if the calling function is Invoke-AzDevOpsApiRestMethod
if (-not($this.TestCallStack('Invoke-AzDevOpsApiRestMethod'))) { throw "[ManagedIdentityToken] The Get() method can only be called within Invoke-AzDevOpsApiRestMethod." }
# Token cannot be returned within a Write-* function. Test to see if the calling function is Write-*
if ($this.TestCallStack('Write-')) { throw "[ManagedIdentityToken] The Get() method cannot be called within a Write-* function." }
# Token cannot be written to a file. Test to see if the calling function is Out-File
if ($this.TestCallStack('Out-File')) { throw "[ManagedIdentityToken] The Get() method cannot be called within Out-File." }

# Return the access token
return ($this.ConvertFromSecureString($this.access_token))

}

}

# Function to create a new ManagedIdentityToken object
Function global:New-ManagedIdentityToken ([hashtable]$ManagedIdentityTokenObj) {

# Create and return a new ManagedIdentityToken object
return [ManagedIdentityToken]::New($ManagedIdentityTokenObj)

}
51 changes: 51 additions & 0 deletions source/Classes/005.APIRateLimit.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
Class APIRateLimit {

[Int]$retryAfter = 0
[Int]$xRateLimitRemaining = 0
[Int]$xRateLimitReset = 0

# Constructor
APIRateLimit([HashTable]$APIRateLimitObj) {

# Validate that APIRateLimitObj is a HashTable and Contains the correct keys
if (-not $this.isValid($APIRateLimitObj)) { throw "The APIRateLimitObj is not valid." }

# Convert X-RateLimit-Reset from Unix Time to DateTime
$epochStart = [datetime]::new(1970, 1, 1, 0, 0, 0, [DateTimeKind]::Utc)

# Set the properties of the class
$this.retryAfter = [Int]($APIRateLimitObj.'Retry-After')
$this.XRateLimitRemaining = [Int]$APIRateLimitObj.'X-RateLimit-Remaining'
$this.XRateLimitReset = [Int]($APIRateLimitObj.'X-RateLimit-Reset')

}

# Constructor with retryAfter Parameters
APIRateLimit($retryAfter) {

# Set the properties of the class
$this.retryAfter = [int]$retryAfter

}

Hidden [Bool]isValid($APIRateLimitObj) {

# Assuming these are the keys we expect in the hashtable
$expectedKeys = @('Retry-After', 'X-RateLimit-Remaining', 'X-RateLimit-Reset')

# Check if all expected keys exist in the hashtable
foreach ($key in $expectedKeys) {
if (-not $APIRateLimitObj.ContainsKey($key)) {
Write-Error "[APIRateLimit] The hashtable does not contain the expected key: $key"
return $false
}
}

# If all checks pass, return true
Write-Verbose "[APIRateLimit] The hashtable is valid and contains all the expected keys."
return $true

}


}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@

<#
.DESCRIPTION
This example shows how to ensure that the Azure DevOps project
called 'Test Project' exists (or is added if it does not exist).
This example uses Invoke-DSCResource to authenticate to Azure DevOps using a Managed Identity.
#>

Configuration Example
{
param
(
[Parameter(Mandatory = $true)]
[string]
$ApiUri
)

Import-DscResource -ModuleName 'AzureDevOpsDsc'

node localhost
{
AzDevOpsProject 'AddProject'
{
Ensure = 'Present'

ApiUri = $ApiUri
Pat = $Pat

ProjectName = 'Test Project'
ProjectDescription = 'A Test Project'

SourceControlType = 'Git'
}

}
}

# Create a new Azure Managed Identity and store the token in a global variable
New-AzManagedIdentity -OrganizationName "Contoso"

# Using Invoke-DSCResource, invoke the 'Test' method of the 'AzDevOpsProject' resource.
# The global variable will be used to authenticate to Azure DevOps.
Invoke-DscResource -Name Example -Method Test -Verbose
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,15 @@ function Get-AzDevOpsApiVersion
$Default
)

[string]$defaultApiVersion = '6.0'
[string]$defaultApiVersion = '6.1'

[string[]]$apiVersions = @(

#'4.0', # Not supported
#'5.0', # Not supported
#'5.1', # Not supported
'6.0'
#'6.1', # Not supported
'6.0',
'6.1'

)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<#
.SYNOPSIS
Obtains a managed identity token from Azure AD.
.DESCRIPTION
The Get-AzManagedIdentityToken function is used to obtain an access token from Azure AD using a managed identity. It can only be called from the New-AzManagedIdentity or Update-AzManagedIdentity functions.
.PARAMETER OrganizationName
Specifies the name of the organization.
.PARAMETER Verify
Specifies whether to verify the connection. If this switch is not set, the function returns the managed identity token. If the switch is set, the function tests the connection and returns the access token.
.EXAMPLE
Get-AzManagedIdentityToken -OrganizationName "Contoso" -Verify
Obtains the access token for the managed identity associated with the organization "Contoso" and verifies the connection.
.NOTES
This function does not require the Azure PowerShell module.
#>

Function Get-AzManagedIdentityToken {
[CmdletBinding()]
param (
# Organization Name
[Parameter(Mandatory)]
[String]
$OrganizationName,

# Verify the Connection
[Parameter()]
[Switch]
$Verify
)

# Obtain the access token from Azure AD using the Managed Identity

$ManagedIdentityParams = @{
# Define the Azure instance metadata endpoint to get the access token
Uri = $AzManagedIdentityLocalizedData.Global_Url_Azure_Instance_Metadata_Url -f $AzManagedIdentityLocalizedData.Global_AzureDevOps_Resource_Id
Method = 'Get'
Headers = @{Metadata="true"}
ContentType = 'Application/json'
}

# Invoke the RestAPI
try { $response = Invoke-AzDevOpsApiRestMethod @ManagedIdentityParams } catch { Throw $_ }
# Test the response
if ($null -eq $response.access_token) { throw $AzManagedIdentityLocalizedData.Error_Azure_Instance_Metadata_Service_Missing_Token }

# TypeCast the response to a ManagedIdentityToken object
$ManagedIdentity = New-ManagedIdentityToken -ManagedIdentityTokenObj $response
# Null the response
$null = $response

# Return the token if the verify switch is not set
if (-not($verify)) { return $ManagedIdentity }

# Test the Connection
if (-not(Test-AzManagedIdentityToken $ManagedIdentity)) { throw $AzManagedIdentityLocalizedData.Error_Azure_API_Call_Generic }

# Return the AccessToken
return ($ManagedIdentity)

}
Loading

0 comments on commit afecb3e

Please sign in to comment.