Public/New-PSUAzureServicePrincipal.ps1

function New-PSUAzureServicePrincipal {
    <#
    .SYNOPSIS
        Creates an Azure service principal with all permissions required for CIEM security checks.

    .DESCRIPTION
        Creates an Azure AD app registration and service principal configured with all permissions
        required to run Devolutions CIEM security checks. This includes:
        - Microsoft Graph API application permissions
        - Azure Resource Manager RBAC role assignment (Reader)
        - Key Vault data plane permissions (documented for manual configuration)

        The function uses Get-CIEMRequiredPermission to determine the exact permissions needed.

    .PARAMETER DisplayName
        The display name for the app registration and service principal.
        Default: "Devolutions-CIEM-Scanner"

    .PARAMETER Scope
        The scope for the ARM RBAC role assignment. Can be a subscription or management group.
        Examples:
        - "/subscriptions/<subscription-id>"
        - "/providers/Microsoft.Management/managementGroups/<mg-name>"

    .PARAMETER CredentialType
        The type of credential to create for authentication.
        - ClientSecret: Creates a client secret (default)
        - Certificate: Creates a self-signed certificate

    .PARAMETER CertificateValidityYears
        Number of years the certificate is valid. Only used when CredentialType is Certificate.
        Default: 1

    .PARAMETER SkipRoleAssignment
        Skip the ARM RBAC role assignment. Useful if you want to assign at a different scope later.

    .PARAMETER SkipAdminConsent
        Skip granting admin consent for Graph API permissions. You will need to grant consent manually
        in the Azure Portal.

    .OUTPUTS
        [PSCustomObject] Object containing:
        - ApplicationId: The app registration client ID
        - ObjectId: The app registration object ID
        - ServicePrincipalId: The service principal object ID
        - TenantId: The Azure AD tenant ID
        - ClientSecret: The client secret (if CredentialType is ClientSecret)
        - CertificateThumbprint: The certificate thumbprint (if CredentialType is Certificate)
        - Permissions: The permissions configured

    .EXAMPLE
        New-PSUAzureServicePrincipal -Scope "/subscriptions/12345678-1234-1234-1234-123456789012"
        # Creates a service principal with client secret for the specified subscription

    .EXAMPLE
        New-PSUAzureServicePrincipal -DisplayName "CIEM-Prod" -CredentialType Certificate -Scope "/subscriptions/12345678-1234-1234-1234-123456789012"
        # Creates a service principal with certificate authentication

    .EXAMPLE
        New-PSUAzureServicePrincipal -Scope "/providers/Microsoft.Management/managementGroups/my-mg" -SkipAdminConsent
        # Creates a service principal at management group scope, skipping admin consent

    .NOTES
        Required modules (auto-installed if not present):
        - Az.Accounts (for Connect-AzAccount)
        - Az.Resources (for app/SP/role cmdlets)
        - Microsoft.Graph.Applications (for Graph API permission grants, unless -SkipAdminConsent)

        Required Azure permissions:
        - Global Administrator or Privileged Role Administrator role (for admin consent)
        - Owner or User Access Administrator role on the target scope (for role assignment)
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([PSCustomObject])]
    param(
        [Parameter()]
        [string]$DisplayName = "Devolutions-CIEM-Scanner",

        [Parameter(Mandatory)]
        [ValidatePattern('^/(subscriptions/[a-f0-9-]+|providers/Microsoft\.Management/managementGroups/.+)$')]
        [string]$Scope,

        [Parameter()]
        [ValidateSet('ClientSecret', 'Certificate')]
        [string]$CredentialType = 'ClientSecret',

        [Parameter()]
        [ValidateRange(1, 10)]
        [int]$CertificateValidityYears = 1,

        [Parameter()]
        [switch]$SkipRoleAssignment,

        [Parameter()]
        [switch]$SkipAdminConsent
    )

    $ErrorActionPreference = 'Stop'

    #region Prerequisites Check
    Write-Verbose "Checking prerequisites..."

    # Install required modules if not present
    $requiredModules = @('Az.Accounts', 'Az.Resources')
    if (-not $SkipAdminConsent) {
        $requiredModules += 'Microsoft.Graph.Applications'
    }

    foreach ($moduleName in $requiredModules) {
        if (-not (Get-Module -ListAvailable -Name $moduleName)) {
            Write-Verbose "Installing required module: $moduleName"
            Install-Module -Name $moduleName -Scope CurrentUser -Force -AllowClobber
        }
        Import-Module -Name $moduleName -ErrorAction Stop
    }

    # Check Azure connection
    $context = Get-AzContext
    if (-not $context) {
        throw "Not connected to Azure. Run Connect-AzAccount first."
    }

    $tenantId = $context.Tenant.Id
    Write-Verbose "Connected to tenant: $tenantId"
    #endregion

    #region Get Required Permissions
    Write-Verbose "Getting required permissions from CIEM checks..."
    $permissions = Get-CIEMRequiredPermission
    Write-Verbose "Found $($permissions.CheckCount) checks requiring permissions"
    Write-Verbose "Graph permissions: $($permissions.Graph.Count)"
    Write-Verbose "ARM permissions: $($permissions.ARM.Count)"
    Write-Verbose "Key Vault data plane permissions: $($permissions.KeyVaultDataPlane.Count)"
    #endregion

    #region Create App Registration
    if ($PSCmdlet.ShouldProcess($DisplayName, "Create Azure AD app registration")) {
        Write-Verbose "Creating app registration: $DisplayName"

        # Check if app already exists
        $existingApp = Get-AzADApplication -DisplayName $DisplayName -ErrorAction SilentlyContinue
        if ($existingApp) {
            throw "An app registration with name '$DisplayName' already exists (AppId: $($existingApp.AppId)). Choose a different name or remove the existing app."
        }

        # Create the app registration
        $appParams = @{
            DisplayName    = $DisplayName
            SignInAudience = 'AzureADMyOrg'
        }
        $app = New-AzADApplication @appParams
        Write-Verbose "Created app registration: $($app.AppId)"
    }
    #endregion

    #region Create Service Principal
    if ($PSCmdlet.ShouldProcess($DisplayName, "Create service principal")) {
        Write-Verbose "Creating service principal..."
        $sp = New-AzADServicePrincipal -ApplicationId $app.AppId
        Write-Verbose "Created service principal: $($sp.Id)"

        # Wait for replication
        Write-Verbose "Waiting for Azure AD replication..."
        Start-Sleep -Seconds 10
    }
    #endregion

    #region Create Credential
    $credential = $null
    $certificateThumbprint = $null

    if ($CredentialType -eq 'ClientSecret') {
        if ($PSCmdlet.ShouldProcess($DisplayName, "Create client secret")) {
            Write-Verbose "Creating client secret..."
            $endDate = (Get-Date).AddYears(1)
            $secretCredential = New-AzADAppCredential -ApplicationId $app.AppId -EndDate $endDate
            $credential = $secretCredential.SecretText
            Write-Verbose "Created client secret (expires: $endDate)"
        }
    }
    else {
        if ($PSCmdlet.ShouldProcess($DisplayName, "Create self-signed certificate")) {
            Write-Verbose "Creating self-signed certificate..."
            $certName = "CN=$DisplayName"
            $endDate = (Get-Date).AddYears($CertificateValidityYears)

            # Create self-signed certificate
            $cert = New-SelfSignedCertificate `
                -Subject $certName `
                -CertStoreLocation "Cert:\CurrentUser\My" `
                -KeyExportPolicy Exportable `
                -KeySpec Signature `
                -KeyLength 2048 `
                -KeyAlgorithm RSA `
                -HashAlgorithm SHA256 `
                -NotAfter $endDate

            $certificateThumbprint = $cert.Thumbprint
            Write-Verbose "Created certificate with thumbprint: $certificateThumbprint"

            # Upload certificate to app registration
            $certBase64 = [System.Convert]::ToBase64String($cert.GetRawCertData())
            New-AzADAppCredential -ApplicationId $app.AppId -CertValue $certBase64 -EndDate $endDate
            Write-Verbose "Uploaded certificate to app registration"
        }
    }
    #endregion

    #region Add Graph API Permissions
    if ($permissions.Graph.Count -gt 0) {
        if ($PSCmdlet.ShouldProcess($DisplayName, "Add Microsoft Graph API permissions")) {
            Write-Verbose "Adding Microsoft Graph API permissions..."

            # Microsoft Graph app ID (well-known)
            $graphAppId = "00000003-0000-0000-c000-000000000000"

            # Get Microsoft Graph service principal to look up permission IDs
            $graphSp = Get-AzADServicePrincipal -ApplicationId $graphAppId

            foreach ($permissionName in $permissions.Graph) {
                Write-Verbose " Adding permission: $permissionName"

                # Find the permission ID from the Graph service principal's app roles
                $appRole = $graphSp.AppRole | Where-Object { $_.Value -eq $permissionName }
                if (-not $appRole) {
                    Write-Warning "Could not find Graph permission: $permissionName"
                    continue
                }

                # Add the permission to the app registration
                Add-AzADAppPermission `
                    -ApplicationId $app.AppId `
                    -ApiId $graphAppId `
                    -PermissionId $appRole.Id `
                    -Type Role
            }
            Write-Verbose "Added $($permissions.Graph.Count) Graph API permissions"
        }
    }
    #endregion

    #region Grant Admin Consent
    if (-not $SkipAdminConsent -and $permissions.Graph.Count -gt 0) {
        if ($PSCmdlet.ShouldProcess($DisplayName, "Grant admin consent for Graph API permissions")) {
            Write-Verbose "Granting admin consent for Graph API permissions..."
            Write-Verbose "Getting access token from current Az context..."

            try {
                # Get Graph token from current Az context (admin user or SP with admin permissions)
                $graphToken = Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com" -ErrorAction Stop

                # Handle both string and SecureString token formats (Az.Accounts version differences)
                if ($graphToken.Token -is [System.Security.SecureString]) {
                    $secureToken = $graphToken.Token
                } else {
                    $secureToken = ConvertTo-SecureString $graphToken.Token -AsPlainText -Force
                }

                Connect-MgGraph -AccessToken $secureToken -NoWelcome -ErrorAction Stop

                # Microsoft Graph service principal
                $graphAppId = "00000003-0000-0000-c000-000000000000"
                $graphSp = Get-MgServicePrincipal -Filter "appId eq '$graphAppId'"

                # Get our service principal
                $ourSp = Get-MgServicePrincipal -Filter "appId eq '$($app.AppId)'"

                foreach ($permissionName in $permissions.Graph) {
                    $appRole = $graphSp.AppRoles | Where-Object { $_.Value -eq $permissionName }
                    if (-not $appRole) {
                        Write-Warning "Could not find Graph permission for consent: $permissionName"
                        continue
                    }

                    Write-Verbose " Granting consent for: $permissionName"
                    try {
                        New-MgServicePrincipalAppRoleAssignment `
                            -ServicePrincipalId $ourSp.Id `
                            -PrincipalId $ourSp.Id `
                            -ResourceId $graphSp.Id `
                            -AppRoleId $appRole.Id `
                            -ErrorAction Stop | Out-Null
                    }
                    catch {
                        if ($_.Exception.Message -notlike "*already exists*") {
                            Write-Warning "Failed to grant consent for $permissionName : $_"
                        }
                    }
                }
                Write-Verbose "Admin consent granted for Graph API permissions"

                Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
            }
            catch {
                Write-Warning "Failed to grant admin consent: $_"
                Write-Warning "Ensure you are logged in as an admin user (Connect-AzAccount) with permission to grant consent."
                Write-Warning "Or grant admin consent manually: Azure Portal > App registrations > $DisplayName > API permissions > Grant admin consent"
            }
        }
    }
    elseif ($permissions.Graph.Count -gt 0) {
        Write-Warning @"
Admin consent was skipped. You must grant admin consent manually:
1. Go to Azure Portal > App registrations > $DisplayName > API permissions
2. Click 'Grant admin consent for <tenant>'

Or run: Connect-MgGraph -Scopes 'Application.ReadWrite.All' and grant consent programmatically.
"@

    }
    #endregion

    #region Assign Reader Role
    if (-not $SkipRoleAssignment) {
        if ($PSCmdlet.ShouldProcess($Scope, "Assign Reader role to service principal")) {
            Write-Verbose "Assigning Reader role at scope: $Scope"

            # Check if assignment already exists
            $existingAssignment = Get-AzRoleAssignment `
                -ObjectId $sp.Id `
                -Scope $Scope `
                -RoleDefinitionName "Reader" `
                -ErrorAction SilentlyContinue

            if ($existingAssignment) {
                Write-Verbose "Reader role already assigned at this scope"
            }
            else {
                New-AzRoleAssignment `
                    -ObjectId $sp.Id `
                    -Scope $Scope `
                    -RoleDefinitionName "Reader" | Out-Null
                Write-Verbose "Reader role assigned successfully"
            }
        }
    }
    else {
        $kvRoles = @()
        if ($permissions.KeyVaultDataPlane -contains 'secrets/list') { $kvRoles += 'Key Vault Secrets User' }
        if ($permissions.KeyVaultDataPlane -contains 'keys/list') { $kvRoles += 'Key Vault Crypto User' }

        Write-Warning @"
Role assignment was skipped. You must assign roles manually:
  New-AzRoleAssignment -ObjectId '$($sp.Id)' -Scope '<scope>' -RoleDefinitionName 'Reader'
$(if ($kvRoles.Count -gt 0) {
$kvRoles | ForEach-Object { " New-AzRoleAssignment -ObjectId '$($sp.Id)' -Scope '<scope>' -RoleDefinitionName '$_'" }
})

The Reader role covers these ARM permissions:
$($permissions.ARM -join "`n")
$(if ($kvRoles.Count -gt 0) {
"`nKey Vault data plane roles required: $($kvRoles -join ', ')"
})
"@

    }
    #endregion

    #region Assign Key Vault Data Plane Roles
    if (-not $SkipRoleAssignment -and $permissions.KeyVaultDataPlane.Count -gt 0) {
        # Map data plane permissions to RBAC roles
        $kvRolesToAssign = @()
        if ($permissions.KeyVaultDataPlane -contains 'secrets/list') {
            $kvRolesToAssign += 'Key Vault Secrets User'
        }
        if ($permissions.KeyVaultDataPlane -contains 'keys/list') {
            $kvRolesToAssign += 'Key Vault Crypto User'
        }

        foreach ($roleName in $kvRolesToAssign) {
            if ($PSCmdlet.ShouldProcess($Scope, "Assign $roleName role to service principal")) {
                Write-Verbose "Assigning $roleName role at scope: $Scope"

                $existingAssignment = Get-AzRoleAssignment `
                    -ObjectId $sp.Id `
                    -Scope $Scope `
                    -RoleDefinitionName $roleName `
                    -ErrorAction SilentlyContinue

                if ($existingAssignment) {
                    Write-Verbose "$roleName role already assigned at this scope"
                }
                else {
                    New-AzRoleAssignment `
                        -ObjectId $sp.Id `
                        -Scope $Scope `
                        -RoleDefinitionName $roleName | Out-Null
                    Write-Verbose "$roleName role assigned successfully"
                }
            }
        }

        Write-Verbose "Key Vault RBAC roles assigned. Note: This only works for vaults using Azure RBAC authorization mode."
    }
    #endregion

    #region Update config.json
    if ($PSCmdlet.ShouldProcess("config.json", "Update with new service principal credentials")) {
        Write-Verbose "Updating config.json with new service principal credentials..."

        # Update the in-memory config
        $script:Config.azure.authentication.tenantId = $tenantId
        if ($CredentialType -eq 'ClientSecret') {
            $script:Config.azure.authentication.servicePrincipal.clientId = $app.AppId
            $script:Config.azure.authentication.servicePrincipal.clientSecret = $credential
        }
        else {
            $script:Config.azure.authentication.certificate.clientId = $app.AppId
            $script:Config.azure.authentication.certificate.thumbprint = $certificateThumbprint
        }

        # Write to config.json file
        $configPath = Join-Path -Path $script:ModuleRoot -ChildPath 'config.json'
        $script:Config | ConvertTo-Json -Depth 10 | Set-Content -Path $configPath -Encoding UTF8
        Write-Verbose "config.json updated successfully"
    }
    #endregion

    #region Build Output
    $output = [PSCustomObject]@{
        DisplayName          = $DisplayName
        ApplicationId        = $app.AppId
        ObjectId             = $app.Id
        ServicePrincipalId   = $sp.Id
        TenantId             = $tenantId
        ClientSecret         = $credential
        CertificateThumbprint = $certificateThumbprint
        Scope                = $Scope
        Permissions          = [PSCustomObject]@{
            Graph             = $permissions.Graph
            ARM               = $permissions.ARM
            KeyVaultDataPlane = $permissions.KeyVaultDataPlane
            CheckCount        = $permissions.CheckCount
        }
        ConfigurationSample  = @"
# Add this to your CIEM configuration:
`$config = @{
    azure = @{
        authentication = @{
            method = '$(if ($CredentialType -eq 'ClientSecret') { 'ServicePrincipalSecret' } else { 'ServicePrincipalCertificate' })'
            tenantId = '$tenantId'
            $(if ($CredentialType -eq 'ClientSecret') {
                "servicePrincipal = @{
                clientId = '$($app.AppId)'
                clientSecret = '<stored-securely>'
            }"
            } else {
                "certificate = @{
                clientId = '$($app.AppId)'
                thumbprint = '$certificateThumbprint'
            }"
            })
        }
    }
}
"@

    }

    Write-Host "`nService principal created successfully!" -ForegroundColor Green
    Write-Host "Application (client) ID: $($app.AppId)"
    Write-Host "Tenant ID: $tenantId"
    if ($credential) {
        Write-Host "`nIMPORTANT: Save the client secret now - it cannot be retrieved later!" -ForegroundColor Yellow
        Write-Host "Client Secret: $credential"
    }
    if ($certificateThumbprint) {
        Write-Host "Certificate Thumbprint: $certificateThumbprint"
        Write-Host "Certificate Location: Cert:\CurrentUser\My\$certificateThumbprint"
    }

    $output
    #endregion
}