Azure AD groups in a CI/CD Pipeline, Stage 2: Plan & Evaluate

The PowerShell I’m writing, which in part mimics the stages that Terraform goes through when deploying Azure resources, adds some “smarts” to the Pipeline.

This is the second stage, in the three stage pipeline for managing Azure AD groups:

This post covers the YAML and PowerShell involved in the second stage which creates a plan of actions (if any), after evaluating the validated group input against Azure AD. The PowerShell can also be called directly.

Current Import & Validate Status Current Plan & Evaluate Status
Build Status Build Status

Invoke Plan Azure AD group

This function is Invoke-WTPlanAzureADGroup, which you can access from my GitHub.

Within the pipeline, this imports the validated JSON artifact of groups (should they exist), which is passed to the function via a parameter. This then creates a plan of what should be created, updated or removed (as appropriate).

Outputting a JSON plan file (as appropriate) as a pipeline artifact for the next stage in the pipeline.

Pipeline YAML example below:

Triggered on a change to the Azure AD groups within the GraphAPIConfig template repo in GitHub

As Azure AD groups can be created in multiple ways, and by multiple applications, having the config repo being the source of authority didn’t seem appropriate, so by default, groups are not removed if they exist in Azure AD and do not exist in the config repo. In the future I might consider a “state” file, similar to Terraform to keep track of this.

Azure Pipelines automatically downloads artifacts created in the previous stage

Expand code block
- stage: Plan
  pool:
    vmImage: 'windows-latest'
  dependsOn: Validate
  condition: and(succeeded(), eq(dependencies.Validate.outputs['Import.InvokeWTValidateAzureADGroup.ShouldRun'], 'true'))
  jobs:
  - job: Evaluate
    continueOnError: false
    steps:
    - task: [email protected]
      inputs:
        buildType: 'current'
        targetPath: '$(Pipeline.Workspace)'
    - task: [email protected]
      name: CloneGraphAPI
      displayName: Clone Graph API repo
      inputs:
        script: 'git clone --branch $(Branch) --single-branch https://github.com/wesley-trust/GraphAPI.git'
        workingDirectory: '$(System.ArtifactsDirectory)'
    - task: [email protected]
      name: CloneToolKit
      displayName: Clone Toolkit repo
      inputs:
        script: 'git clone --branch $(Branch) --single-branch https://github.com/wesley-trust/ToolKit.git'
        workingDirectory: '$(System.ArtifactsDirectory)'
    - task: [email protected]
      name: InvokeWTPlanAzureADGroup
      displayName: Invoke-WTPlanAzureADGroup
      inputs:
        targetType: 'inline'
        script: |

          # Import and convert Groups from JSON, should they exist
          $TestPath = Test-Path $(Pipeline.Workspace)\Import\Validate.json -PathType Leaf
          if ($TestPath){
              $ValidateAzureADGroups = Get-Content -Raw -Path $(Pipeline.Workspace)\Import\Validate.json | ConvertFrom-Json -Depth 10
          }

          # Dot source and execute function
          . $(System.ArtifactsDirectory)\GraphAPI\Public\AzureAD\Groups\Pipeline\Invoke-WTPlanAzureADGroup.ps1
            $PlanAzureADGroups = Invoke-WTPlanAzureADGroup `
              -TenantDomain $(TenantDomain) `
              -ClientID ${env:CLIENTID} `
              -ClientSecret ${env:CLIENTSECRET} `
              -AzureADGroups $ValidateAzureADGroups `
              -UpdateExistingGroups

          # Create directory for artifact, if it does not exist
          $TestPath = Test-Path $(Pipeline.Workspace)\Output -PathType Container
          if (!$TestPath){
              New-Item -Path $(Pipeline.Workspace)\Output -ItemType Directory | Out-Null
          }

          # If there are Groups
          if ($PlanAzureADGroups.RemoveGroups -or $PlanAzureADGroups.UpdateGroups -or $PlanAzureADGroups.CreateGroups){

            # Set ShouldRun variable to true, for apply stage
            echo "##vso[task.setvariable variable=ShouldRun;isOutput=true]true"

            # Convert to JSON and export
            $PlanAzureADGroups | ConvertTo-Json -Depth 10 | Out-File -Force -FilePath $(Pipeline.Workspace)\Output\Plan.json
          }
        pwsh: true
        workingDirectory: '$(System.ArtifactsDirectory)'
      env:
        CLIENTID: $(ClientID)
        CLIENTSECRET: $(ClientSecret)
    - task: [email protected]
      inputs:
        targetPath: '$(Pipeline.Workspace)\Output'
        artifact: 'Evaluate'
        publishLocation: 'pipeline'

PowerShell example below:

Expand code block
# Clone repo that contains the Graph API and ToolKit functions
git clone --branch main --single-branch https://github.com/wesley-trust/GraphAPI.git
git clone --branch main --single-branch https://github.com/wesley-trust/ToolKit.git

# Dot source function into memory
. .\GraphAPI\Public\AzureAD\Groups\Pipeline\Invoke-WTPlanAzureADGroup.ps1

# Define Variables
$ClientID = "sdg23497-sd82-983s-sdf23-dsf234kafs24"
$ClientSecret = "khsdfhbdfg723498345_sdfkjbdf~-SDFFG1"
$TenantDomain = "wesleytrustsandbox.onmicrosoft.com"
$AccessToken = "HWYLAqz6PipzzdtPwRnSN0Socozs2lZ7nsFky90UlDGTmaZY1foVojTUqFgm1vw0iBslogoP"

# Example valid group (mailNickName if missing, is auto-generated upon creation)
$ValidateAzureADGroup = [PSCustomObject]@{
    displayName     = "SVC-CA; Exclude from all Conditional Access Policies"
    mailEnabled     = $false
    securityEnabled = $true
}

# Create hashtable
$Parameters = @{
  ClientID             = $ClientID
  ClientSecret         = $ClientSecret
  TenantDomain         = $TenantDomain
  UpdateExistingGroups = $true
  AzureADGroup         = $ValidateAzureADGroup
}

# Create a plan, splatting the hashtable of parameters
Invoke-WTPlanAzureADGroup @Parameters

# Or pipe specific object definitions to the plan function, with an access token previously obtained
$ValidateAzureADGroup | Invoke-WTPlanAzureADGroup -AccessToken $AccessToken

# Or specify each parameter individually, with an access token previously obtained
Invoke-WTPlanAzureADGroup -AzureADGroup $ValidateAzureADGroup -AccessToken $AccessToken -UpdateExistingGroups

What does this do?

  • An access token is obtained, if one is not provided, this allows the same token to be shared within the pipeline
  • Checks are performed about whether to evaluate groups for updating or removal
  • Existing groups in Azure AD are obtained (as appropriate) from the Get function, in order to compare against the validated import
  • An object comparison is performed on the group IDs, determining:
    • What groups could be removed (as they exist, but don’t have an ID in the import)
    • What groups could be created (as an ID might not exist, or might not match an existing ID in Azure AD)
  • A safety check is performed, if no groups are provided, the removal of all existing groups requires a “Force” switch
  • If groups should not be removed, the variable for removing groups is cleared
  • If groups should be updated, and there are existing groups in Azure AD, only groups with valid IDs are included
  • An object comparison is then performed on specific object properties, to check for specific differences (only)
    • If there are differences, they’re added to a variable
  • If no groups exist, any imported groups must all be created, so the variable is updated
  • An object is then built containing the groups to be removed, updated or created (as appropriate)
  • This object is then returned as a plan of action, which is output as a pipeline artifact for the next stage

The complete function as at this date, is below:

Expand code block (always grab the latest version from GitHub)
function Invoke-WTPlanAzureADGroup {
    [cmdletbinding()]
    param (
        [parameter(
            Mandatory = $false,
            ValueFromPipeLineByPropertyName = $true,
            HelpMessage = "Client ID for the Azure AD service principal with Azure AD Graph permissions"
        )]
        [string]$ClientID,
        [parameter(
            Mandatory = $false,
            ValueFromPipeLineByPropertyName = $true,
            HelpMessage = "Client secret for the Azure AD service principal with Azure AD Graph permissions"
        )]
        [string]$ClientSecret,
        [parameter(
            Mandatory = $false,
            ValueFromPipeLineByPropertyName = $true,
            HelpMessage = "The initial domain (onmicrosoft.com) of the tenant"
        )]
        [string]$TenantDomain,
        [parameter(
            Mandatory = $false,
            ValueFromPipeLineByPropertyName = $true,
            HelpMessage = "The access token, obtained from executing Get-WTGraphAccessToken"
        )]
        [string]$AccessToken,
        [parameter(
            Mandatory = $false,
            ValueFromPipeLineByPropertyName = $true,
            ValueFromPipeLine = $true,
            HelpMessage = "The Azure AD group object"
        )]
        [Alias('AzureADGroup', 'GroupDefinition')]
        [PSCustomObject]$AzureADGroups,
        [Parameter(
            Mandatory = $false,
            ValueFromPipeLineByPropertyName = $true,
            HelpMessage = "Specify whether to update existing groups deployed in the tenant, where the IDs match"
        )]
        [switch]
        $UpdateExistingGroups,
        [Parameter(
            Mandatory = $false,
            ValueFromPipeLineByPropertyName = $true,
            HelpMessage = "Specify whether existing groups deployed in the tenant will be removed, if not present in the import"
        )]
        [switch]
        $RemoveExistingGroups,
        [parameter(
            Mandatory = $false,
            ValueFromPipeLineByPropertyName = $true,
            HelpMessage = "Specify whether to exclude features in preview, a production API version will be used instead"
        )]
        [switch]$ExcludePreviewFeatures,
        [parameter(
            Mandatory = $false,
            ValueFromPipeLineByPropertyName = $true,
            HelpMessage = "If there are no groups to import, whether to forcibly remove any existing groups"
        )]
        [switch]$Force
    )
    Begin {
        try {
            # Function definitions
            $Functions = @(
                "GraphAPI\Public\Authentication\Get-WTGraphAccessToken.ps1",
                "Toolkit\Public\Invoke-WTPropertyTagging.ps1",
                "GraphAPI\Public\AzureAD\Groups\Get-WTAzureADGroup.ps1"
            )

            # Function dot source
            foreach ($Function in $Functions) {
                . $Function
            }

        }
        catch {
            Write-Error -Message $_.Exception
            throw $_.exception
        }
    }
    Process {
        try {
            
            # If there is no access token, obtain one
            if (!$AccessToken) {
                $AccessToken = Get-WTGraphAccessToken `
                    -ClientID $ClientID `
                    -ClientSecret $ClientSecret `
                    -TenantDomain $TenantDomain
            }

            if ($AccessToken) {

                # Output current action
                Write-Host "Evaluating Azure AD Groups"
                
                # Build Parameters
                $Parameters = @{
                    AccessToken = $AccessToken
                }
                if ($ExcludePreviewFeatures) {
                    $Parameters.Add("ExcludePreviewFeatures", $true)
                }

                # Evaluate groups if parameters exist
                if ($RemoveExistingGroups -or $UpdateExistingGroups) {

                    # Get existing groups for comparison
                    $ExistingGroups = Get-WTAzureADGroup @Parameters

                    if ($ExistingGroups) {

                        if ($AzureADGroups) {

                            # Compare object on id and pass thru all objects, including those that exist and are to be imported
                            $GroupComparison = Compare-Object `
                                -ReferenceObject $ExistingGroups `
                                -DifferenceObject $AzureADGroups `
                                -Property id `
                                -PassThru

                            # Filter for groups that should be removed, as they do not exist in the import
                            $RemoveGroups = $GroupComparison | Where-Object { $_.sideindicator -eq "<=" }

                            # Filter for groups that did not contain an id, and so are groups that should be created
                            $CreateGroups = $GroupComparison | Where-Object { $_.sideindicator -eq "=>" }
                        }
                        else {

                            # If force is enabled, then if removal of groups is specified, all existing will be removed
                            if ($Force) {
                                $RemoveGroups = $ExistingGroups
                            }
                        }

                        if (!$RemoveExistingGroups) {

                            # If groups are not to be removed, disregard any groups for removal
                            $RemoveGroups = $null
                        }
                        if ($UpdateExistingGroups) {
                            if ($AzureADGroups) {
                                
                                # Check whether the groups that could be updated have valid ids (so can be updated, ignore the rest)
                                $UpdateGroups = foreach ($Group in $AzureADGroups) {
                                    if ($Group.id -in $ExistingGroups.id) {
                                        $Group
                                    }
                                }

                                # If groups exist, with ids that matched the import
                                if ($UpdateGroups) {
                            
                                    # Compare again, with all mandatory property elements for differences
                                    $GroupPropertyComparison = Compare-Object `
                                        -ReferenceObject $ExistingGroups `
                                        -DifferenceObject $UpdateGroups `
                                        -Property id, displayName, description, membershipRule

                                    $UpdateGroups = $GroupPropertyComparison | Where-Object { $_.sideindicator -eq "=>" }
                                }
                            }
                        }
                    }
                    else {
                        # If no groups exist, any imported must be created
                        $CreateGroups = $AzureADGroups
                    }
                }
                else {
                    # If no groups are to be removed or updated, any imported must be created
                    $CreateGroups = $AzureADGroups
                }
                
                # Build object to return
                $PlanAzureADGroups = [ordered]@{}

                if ($RemoveGroups) {
                    $PlanAzureADGroups.Add("RemoveGroups", $RemoveGroups)
                    
                    # Output current action
                    Write-Host "Groups to remove: $($RemoveGroups.count)"

                    foreach ($Group in $RemoveGroups) {
                        Write-Host "Remove: Group ID: $($Group.id)" -ForegroundColor DarkRed
                    }
                }
                else {
                    Write-Host "No groups will be removed, as none exist that are different to the import"
                }
                if ($UpdateGroups) {
                    $PlanAzureADGroups.Add("UpdateGroups", $UpdateGroups)
                                        
                    # Output current action
                    Write-Host "Groups to update: $($UpdateGroups.count)"
                    
                    foreach ($Group in $UpdateGroups) {
                        Write-Host "Update: Group ID: $($Group.id)" -ForegroundColor DarkYellow
                    }
                }
                else {
                    Write-Host "No groups will be updated, as none exist that are different to the import"
                }
                if ($CreateGroups) {
                    $PlanAzureADGroups.Add("CreateGroups", $CreateGroups)
                                        
                    # Output current action
                    Write-Host "Groups to create: $($CreateGroups.count)"

                    foreach ($Group in $CreateGroups) {
                        Write-Host "Create: Group Name: $($Group.displayName)" -ForegroundColor DarkGreen
                    }
                }
                else {
                    Write-Host "No groups will be created, as none exist that are different to the import"
                }

                # If there are groups, return PS object
                if ($PlanAzureADGroups) {
                    $PlanAzureADGroups = [PSCustomObject]$PlanAzureADGroups
                    $PlanAzureADGroups
                }
            }
            else {
                $ErrorMessage = "No access token specified, obtain an access token object from Get-WTGraphAccessToken"
                Write-Error $ErrorMessage
                throw $ErrorMessage
            }
        }
        catch {
            Write-Error -Message $_.Exception
            throw $_.exception
        }
    }
    End {
        try {
            
        }
        catch {
            Write-Error -Message $_.Exception
            throw $_.exception
        }
    }
}