functions/azure/aad/Assert-AzureServicePrincipalForRbac.ps1

# <copyright file="Assert-AzureServicePrincipalForRbac.ps1" company="Endjin Limited">
# Copyright (c) Endjin Limited. All rights reserved.
# </copyright>

<#
.SYNOPSIS
Ensures that an Azure AD service principal exists, creating if necessary. Optionally storing the app credential
in Azure Key Vault.
 
.DESCRIPTION
Ensures that a suitable Azure AD application & service principal exists. Optionally storing the app credential
in Azure Key Vault.
 
.PARAMETER Name
The display name of the Azure AD service principal.
 
.PARAMETER CredentialDisplayName
The label applied to the created/updated credential, which is important for traceability purposes.
 
.PARAMETER DefaultSubscriptionId
The default subscriptionId to associate with the credential when storing in Key Vault. This is
required when the credential is to be used with the 'azure/login' GitHub Action.
 
.PARAMETER KeyVaultName
The key vault where that client secret will be stored.
 
.PARAMETER KeyVaultSecretName
The key vault secret name that client secret will be stored in.
 
.PARAMETER RotateSecret
When specified, the client secret will be regenerated.
 
.PARAMETER UseApplicationCredential
When specified, the managed credential will be associated with the App registration, otherwise it will be associated
with the Service Principal object.
 
.OUTPUTS
Returns a tuple containing a hashtable representing the object describing the Azure AD service principal and
it's client secret. Where the client secret is not avilable (e.g. the service principal aleady exists) or the
Key Vault functionality is used, '$null' will be returned for this element.
 
e.g.
@(
    @{ <service-principal-definition> },
    "<client-secret>"
)
 
#>


function Assert-AzureServicePrincipalForRbac
{
    [CmdletBinding(SupportsShouldProcess)]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPlainTextForPassword', 'CredentialDisplayName', Justification='Only holds non-sensitive metadata')]
    param (
        [Parameter(ParameterSetName = 'Default', Mandatory = $true)]
        [Parameter(ParameterSetName = 'KeyVault', Mandatory = $true)]
        [string] $Name,

        [int] $PasswordLifetimeDays = 365,

        [Parameter(Mandatory = $true)]
        [string] $CredentialDisplayName,
        
        [Parameter(ParameterSetName = 'KeyVault',
                    Mandatory = $true)]
        [string] $KeyVaultName,

        [Parameter(ParameterSetName = 'KeyVault',
                    Mandatory = $true)]
        [string] $KeyVaultSecretName,

        [Parameter(ParameterSetName = 'KeyVault')]
        [string] $DefaultSubscriptionId = "",

        [switch] $RotateSecret,

        [Parameter()]
        [switch] $UseApplicationCredential
    )

    $useKeyVault = ($PSCmdlet.ParameterSetName -eq "KeyVault")

    # Check whether we have a valid AzPowerShell connection
    # No subscription-level access is required even when using key vault, just data-plane permissions to it
    _EnsureAzureConnection -AzPowerShell -TenantOnly -ErrorAction Stop | Out-Null

    $credentialSecret = $null
    $existingSp = _getServicePrincipal -DisplayName $Name

    if (!$existingSp) {
        if ($PSCmdlet.ShouldProcess($Name, "Create Service Principal")) {

            # Create a new service principal
            $createParams = @{
                DisplayName = $Name
            }
            $newSp = _newServicePrincipal @createParams
            Write-Host ("Created service principal [ObjectId={0}, ApplicationId={1}]" -f $newSp.id, $newSp.appId)
            
            # Setup the client secret/credential and store it in key vault, if necessary
            if ($UseApplicationCredential) {
                $app = _getApplicationForNewAppCredential -DisplayName $Name
                Write-Host "Credential will be added to app registration [AppId=$($app.appId)]"
                $handleCredSplat = @{ Application = $app }
            }
            else {
                $handleCredSplat = @{ ServicePrincipal = $newSp }
                Write-Host "Credential will be added to service principal [Id=$($newSp.id)]"
            }
            $credentialSecret = _handleCredential @handleCredSplat -UseKeyVault $useKeyVault
        }
    }
    else {
        Write-Host ("Service Principal '{0}' already exists [ObjectId={1}, ApplicationId={2}]" -f `
                            $Name,
                            $existingSp.id,
                            $existingSp.appId)

        $kvSecretIsMissingOrInvalid = $false
        if ($useKeyVault) {
            # if using key vault, check whether the specified secret is available and contains the password
            $kvSecret = Get-AzKeyVaultSecret -VaultName $KeyVaultName -Name $KeyVaultSecretName
            if ($kvSecret) {
                $existingSecretJson = $kvSecret.SecretValue |
                                    ConvertFrom-SecureString -AsPlainText |
                                    ConvertFrom-Json -AsHashtable

                # Validate that the structure of the secret matches the requirements of 'azure/login@v2' GitHub Action
                $requiredKeys = "clientId", "clientSecret", "tenantId"
                $requiredKeys | ForEach-Object {
                    if (!$existingSecretJson.ContainsKey($_)) {
                        $kvSecretIsMissingOrInvalid = $true
                        Write-Warning "Key vault secret does not contain a valid '$_' field - will rotate the secret"
                    }
                }  
            }
            else {
                $kvSecretIsMissingOrInvalid = $true
            }
        }

        # rotate the client secret/credential
        if (($useKeyVault -and $kvSecretIsMissingOrInvalid) -or $RotateSecret) {
            if ($PSCmdlet.ShouldProcess($Name, "Rotate Service Principal Secret")) {
                Write-Host "Rotating service principal credential [UseKeyVault=$useKeyVault, KeyVaultSecretMissingOrInvalid=$($kvSecretIsMissingOrInvalid), RotateFlag=$RotateSecret]"
                $handleCredSplat = @{ DefaultSubscriptionId = $DefaultSubscriptionId }
                if ($UseApplicationCredential) {
                    $app = _getApplicationForNewAppCredential -DisplayName $Name
                    Write-Host "Credential will be added to app registration [AppId=$($app.appId)]"
                    $handleCredSplat += @{ Application = $app }
                }
                else {
                    $handleCredSplat += @{ ServicePrincipal = $existingSp }
                    Write-Host "Credential will be added to service principal [Id=$($existingSp.id)]"
                }

                $credentialSecret = _handleCredential @handleCredSplat -UseKeyVault $useKeyVault
            }
        }
    }

    return ($existingSp ? $existingSp : $newSp),$credentialSecret
}

    #region Helper functions internal to the module
    function _handleCredential {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory=$true, ParameterSetName="Application")]
            [hashtable] $Application,

            [Parameter(Mandatory=$true, ParameterSetName="ServicePrincipal")]
            [hashtable] $ServicePrincipal,

            [Parameter()]
            [bool] $UseKeyVault,

            [Parameter()]
            [string] $DefaultSubscriptionId
        )

        $applicationMode = $PSCmdlet.ParameterSetName -eq "Application"
        if ($applicationMode) {
            Write-Verbose "Credential will be associated with the App registration"
            $baseUri = "https://graph.microsoft.com/v1.0/applications/$($Application.id)"
            # Check whether we have an existing credential with the same display name
            $existingCred = $Application.passwordCredentials |
                                Where-Object { $_.displayName -eq $CredentialDisplayName }
        }
        else {
            Write-Verbose "Credential will be associated with the Service Principal"
            $baseUri = "https://graph.microsoft.com/v1.0/servicePrincipals/$($ServicePrincipal.id)"
            # Check whether we have an existing credential with the same display name
            $existingCred = $ServicePrincipal.passwordCredentials |
                                Where-Object { $_.displayName -eq $CredentialDisplayName }
        }

        # Before adding a credential we need to check if we already added one previously - the number of
        # credentials for an object can be limited. We should be a good citizen and remove our old one before
        # re-generating it
        if ($existingCred) {
            Write-Host "Removing existing credential [DisplayName=$CredentialDisplayName; KeyId=$($existingCred.keyId)]"
            $resp = Invoke-AzRestMethod -Uri "$baseUri/removePassword" `
                                        -Method POST `
                                        -Payload ( @{keyId = $existingCred.keyId} | ConvertTo-Json -Compress ) | _HandleRestError
        }   

        # Now we can generate the new credential - we use the REST API rather than a build cmdlet so that
        # we can set a display name for the credentials. This improves traceability and also helps us find
        # 'our' credential in the future (e.g. when we want to rotate it)
        $body = @{
            passwordCredential = @{
                displayName = $CredentialDisplayName
                endDateTime = ([DateTime]::Now.AddDays($PasswordLifetimeDays))                    
            }
        }
        $resp = Invoke-AzRestMethod -Uri "$baseUri/addPassword" `
                                    -Method POST `
                                    -Payload ($body | ConvertTo-Json -Compress) | _HandleRestError
        $newCred = $resp.Content |
                        ConvertFrom-Json -AsHashtable
        

        # Store the credentials in key vault, if required
        if ($UseKeyVault) {
            # This format of secret is compatible with 'azure/login' GitHub Action, originally this used a format
            # that matched the output 'az ad sp create-for-rbac' command, however this is no longer the case, so we
            # use the format documented below.
            # REF: https://github.com/azure/login?tab=readme-ov-file#creds
            $appLoginDetails = @{
                clientId = ($applicationMode ? $Application.appId : $ServicePrincipal.appId)
                clientSecret = $newCred.secretText
                subscriptionId = $DefaultSubscriptionId
                tenantId = (Get-AzContext).Tenant.Id
            }
            Write-Host "Storing client secret in key vault [VaultName=$KeyVaultName, SecretName=$KeyVaultSecretName]"
            Set-AzKeyVaultSecret -VaultName $KeyVaultName `
                                 -Name $KeyVaultSecretName `
                                 -SecretValue ($appLoginDetails | ConvertTo-Json | ConvertTo-SecureString -AsPlainText -Force) `
                                 -ContentType "application/json" `
                                 -Expires $body.passwordCredential.endDateTime `
                | Out-Null

            return $null
        }
        else {
            return $newCred.secretText
        }
    }
    function _getApplication {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory=$true)]
            $DisplayName
        )

        $resp = Invoke-AzRestMethod -Uri "https://graph.microsoft.com/v1.0/applications?`$filter=displayName eq '$DisplayName'" | _HandleRestError
        $app = $resp.Content |
                    ConvertFrom-Json -AsHashtable -Depth 100 |
                    Select-Object -ExpandProperty value

        return $app
    }
    function _newApplication {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory=$true)]
            $DisplayName
        )

        $payload = @{ displayName = $DisplayName}
        Write-Verbose "Creating app registation object [DisplayName=$DisplayName]"
        $resp = Invoke-AzRestMethod -Uri "https://graph.microsoft.com/v1.0/applications" `
                                    -Method POST `
                                    -Payload ($payload | ConvertTo-Json) | _HandleRestError
        $newApp = $resp.Content |
                    ConvertFrom-Json -AsHashtable
        Write-Verbose "Created app registation object [AppId=$($newApp.appId); Id=$($newApp.id)]"

        return $newApp
    }
    function _getServicePrincipal {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory=$true)]
            $DisplayName
        )

        $resp = Invoke-AzRestMethod -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=displayName eq '$DisplayName'" | _HandleRestError
        $sp = $resp.Content |
                    ConvertFrom-Json -AsHashtable -Depth 100 |
                    Select-Object -ExpandProperty value

        return $sp
    }
    function _newServicePrincipal {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory=$true)]
            $DisplayName
        )

        # Check whether an application object already exists with this display name
        $app = _getApplicationForNewServicePrincipal @PSBoundParameters
        if (!$app) {
            # Create a bare application registration, as the appId
            $app = _newApplication @PSBoundParameters
        }

        # Create the service principal
        $payload = @{ appId = $app.appId}
        Write-Verbose "Creating service principal object [AppId=$($app.appId)]"
        $resp = Invoke-AzRestMethod -Uri "https://graph.microsoft.com/v1.0/servicePrincipals" `
                                     -Method POST `
                                     -Payload ($payload | ConvertTo-Json) | _HandleRestError
        $newSp = $resp.Content |
                    ConvertFrom-Json -AsHashtable
        Write-Verbose "Created service principal object [Id=$($newSp.id)]"

        return $newSp
    }
    
    # These wrapper functions are required for mocking purposes as '_getApplication' is called in 2 separate scenarios
    function _getApplicationForNewServicePrincipal {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory=$true)]
            $DisplayName
        )

        _getApplication @PSBoundParameters
    }
    function _getApplicationForNewAppCredential {
        [CmdletBinding()]
        param (
            [Parameter(Mandatory=$true)]
            $DisplayName
        )

        _getApplication @PSBoundParameters
    }
    #endregion