Export-AzureRoleAssignment.psm1

$version = "1.0.0"

$InformationPreference = 'Continue'
$ErrorActionPreference = 'Stop'

function writelog {
    # Simple logging function with timestamp added
    Param(
        [string]$message,
        [switch]$verbose
    )
    if ($verbose) {
        Write-verbose "$((Get-Date).ToString('yyyy-MM-dd HH:mm:ss.fff'))`t$message"
    }
    else {
        write-information "$((Get-Date).ToString('yyyy-MM-dd HH:mm:ss.fff'))`t$message"
    }
}

class Contexts {
    # load required context data so it is easy to access by subscription name or Id
    [System.Collections.Generic.List[PSCustomObject]]$contexts = @()
    hidden [hashtable] $contextsByName = @{}
    hidden [hashtable] $contextsById = @{}
    [hashtable] $tenantsById = @{}

    Contexts() {

        foreach ($tenant in get-aztenant) {
            $this.tenantsById[$tenant.Id] = $tenant.Name
        }

        foreach ($context in Get-AzContext -ListAvailable) {
            $this.contexts.Add($context)
            $this.contextsByName[$context.Subscription.Name] = ($this.contexts.count - 1)
            $this.contextsById[$context.Subscription.Id] = ($this.contexts.count - 1)
        }

    }

    [PSCustomObject] get([string] $nameOrId) {
        if ($this.contextsByName.ContainsKey($nameOrId)) {
            return $this.contexts[$this.contextsByName[$nameOrId]]
        }
        elseif ($this.contextsById.ContainsKey($nameOrId)) {
            return  $this.contexts[$this.contextsById[$nameOrId]]
        }
        return $null
    }

    [PSCustomObject] exists([string] $nameOrId) {
        if ($this.contextsByName.ContainsKey($nameOrId)) {
            return $true
        }
        elseif ($this.contextsById.ContainsKey($nameOrId)) {
            return  $true
        }
        return $false
    }

    [PSCustomObject[]] enumerate() {
        return $this.contexts | Sort-Object { $_.subscription.name }
    }

}

enum scopeType {
    unknown = -1
    root = 0
    managementGroup = 1
    subscription = 2
    resourceGroup = 3
    resource = 4
}

enum objectType {
    group = 0
    user = 1
    servicePrincipal = 2
}

Class RoleAssignment {

    [string] $scope
    [string] $scopeType
    [string] $role
    [string] $objectName
    [string] $objectType
    [string] $filename

    RoleAssignment() {

    }

    RoleAssignment([pscustomobject] $assignment) {
        $this.scope = $assignment.scope
        $this.scopeType = $assignment.scopeType
        $this.role = $assignment.role
        $this.objectName = $assignment.objectName
        $this.objectType = $assignment.objectType
        $this.filename = $assignment.filename
    }

    RoleAssignment([string] $scope, [string] $scopeType, [string] $role, [string] $objectName, [string] $objectType, [string] $filename) {
        $this.scope = $scope
        $this.scopeType = $scopeType
        $this.role = $role
        $this.objectName = $objectName
        $this.objectType = $objectType
        $this.filename = $filename
    }

    [string] tostring() {
        return $this | convertto-json -Depth 10
    }
}

class Tag {
    [string] $filename
    [string] $scope
    [string] $scopeType
    [string] $name
    [string] $value

    Tag() {

    }

    Tag([PSCustomObject]$tag) {
        $this.filename = $tag.filename
        $this.scope = $tag.scope
        $this.scopeType = $tag.scopeType
        $this.name = $tag.name
        $this.value = $tag.value
    }

    Tag([string] $filename, [string] $scope, [string] $scopeType, [string] $name, [string] $value) {
        $this.filename = $filename
        $this.scope = $scope
        $this.scopeType = $scopeType
        $this.name = $name
        $this.value = $value
    }

}


class ResourceGroup {
    [string] $resourceGroupName
    [string] $Location
    [string] $ResourceId
}


class ResourceGroupFileData {

    ResourceGroupFileData([string] $resourceGroupName, [string] $location, $tags, [System.Collections.Generic.List[pscustomobject]] $assignments) {
        $this.resourceGroupName = $resourceGroupName
        $this.location = $location
        $this.tags = $tags
        $this.assignments = $assignments
    }

    [string] $resourceGroupName
    [string] $location
    [System.Collections.Generic.List[pscustomobject]] $assignments
    $tags
}

class FileData {

    FileData([hashtable] $tags, [System.Collections.Generic.List[pscustomobject]] $assignments) {
        $this.tags = $tags
        $this.assignments = $assignments

    }
    [hashtable] $tags
    [System.Collections.Generic.List[pscustomobject]] $assignments
}


class FilePaths {

    # Used to convert file paths from containing subscription IDs to Subscription Names and vice versa
    # e.g. "filename": "/subscriptions/29801f9a-9479-43be-a612-a92e4dfc7b23" --> "/subscriptions/OSS-SUB-DEV"

    hidden [hashtable] $subscriptionsByName = @{}
    hidden [hashtable] $subscriptionsById = @{}

    FilePaths($azcontexts) {

        foreach ($context in $azcontexts.enumerate()) {
            $this.Add($context.Subscription.Id, $context.Subscription.Name)
        }
    }

    Add([string] $id, [string] $name) {
        $this.subscriptionsByName[$name] = $id
        $this.subscriptionsById[$id] = $name
    }

    [string] ConvertToName($filename) {

        if ($filename -match '/subscriptions/([^/]*)') {
            $subId = $matches[1]
            $subName = $this.subscriptionsById[$subId]
            return $filename.replace("/subscriptions/$subId", "/subscriptions/$subName")
        }

        return $filename

    }

    [string] ConvertToId($filename) {

        if ($filename -match '/subscriptions/([^/]*)') {
            $subName = $matches[1]
            $subId = $this.subscriptionsByName[$subName]
            return $filename.replace("/subscriptions/$subName", "/subscriptions/$subId")
        }

        return $filename

    }

}

class RoleAssignmentData {
    hidden $subscriptions
    hidden $outputFormat
    $fromAzureAssignments = [System.Collections.Generic.List[RoleAssignment]]::new()
    hidden [hashtable] $fromAzureAssignmentsHash = @{} # used to ensure no duplicates
    hidden [hashtable] $fromAzureAssignmentsfileIndex = @{}

    $fromAzureResourceGroups = @{}

    $fromAzureTags = [System.Collections.Generic.List[Tag]]::new()
    hidden [hashtable] $fromAzureTagsHash = @{} # used to ensure no duplicates
    hidden [hashtable] $fromAzureTagsfileIndex = @{}

    hidden $contexts

    hidden $FilePaths
    RoleAssignmentData($contexts) {
        $this.contexts = $contexts
        $this.FilePaths = [FilePaths]::new($this.contexts)
    }


    hidden [void] fromAzureAssignmentAdd([pscustomobject] $assignment) {

        $RoleAssignment = [RoleAssignment]::new()
        $RoleAssignment.scopeType, $RoleAssignment.fileName = $this.getScopeTypeandFileName($assignment.scope)
        $RoleAssignment.scope = $assignment.scope -replace $RoleAssignment.fileName, ""
        $RoleAssignment.role = $assignment.RoleDefinitionName
        $RoleAssignment.ObjectType = $assignment.ObjectType

        if ($assignment.ObjectType -eq "user") {
            $RoleAssignment.objectName = $assignment.SignInName
        }
        else {
            $RoleAssignment.objectName = $assignment.DisplayName
        }

        $Hash = "{0}`t{1}`t{2}`t{3}`t{4}" -f $RoleAssignment.fileName, $RoleAssignment.scope, $RoleAssignment.role, $RoleAssignment.ObjectType, $RoleAssignment.objectName

        if ($this.fromAzureAssignmentsHash.ContainsKey($Hash)) {
            # Already exists
        }
        else {
            $this.fromAzureAssignments.Add($RoleAssignment)
            $index = $this.fromAzureAssignments.count - 1

            $this.fromAzureIndexAssignmentFile($RoleAssignment.fileName, $index)
            $this.fromAzureAssignmentsHash[$hash] = 1
        }

        # $this.identities.add([AADIdentity]::new($RoleAssignment.objectName, $assignment.ObjectId, $RoleAssignment.ObjectType, [AADIdentitySource]::azure))

    }


    hidden [void] fromAzureTagAdd($rg) {

        $tags = $rg.tags

        $scope = $rg.ResourceId
        $scopeType, $fileName = $this.getScopeTypeandFileName($scope)
        $scope = $scope -replace $fileName, ""

        foreach ($key in $tags.keys) {

            $newTag = [Tag]::new($fileName, $scope, $scopeType, $key, $tags[$key])
            $Hash = "{0}`t{1}`t{2}`t{3}" -f $newTag.fileName, $newTag.scope, $newTag.scopeType, $newTag.name

            if ($this.fromAzureTagsHash.ContainsKey($Hash)) {
                # Already exists
            }
            else {
                $this.fromAzureTags.Add($newTag)
                $index = $this.fromAzureTags.count - 1

                $this.fromAzureIndexTagFile($newTag.fileName, $index)
                $this.fromAzureTagsHash[$hash] = 1
            }
        }
    }

    hidden [void] fromAzureIndexAssignmentFile($fileName, $index) {
        if (-not $this.fromAzureAssignmentsfileIndex.ContainsKey($fileName)) {
            $indexList = [System.Collections.Generic.List[int]]::new()
            $indexList.Add($index)
            $this.fromAzureAssignmentsfileIndex[$fileName] = $indexList
        }
        else {
            $this.fromAzureAssignmentsfileIndex[$fileName].Add($index)
        }
    }

    hidden [void] fromAzureIndexTagFile($fileName, $index) {
        if (-not $this.fromAzureTagsfileIndex.ContainsKey($fileName)) {
            $indexList = [System.Collections.Generic.List[int]]::new()
            $indexList.Add($index)
            $this.fromAzureTagsfileIndex[$fileName] = $indexList
        }
        else {
            $this.fromAzureTagsfileIndex[$fileName].Add($index)
        }
    }

    hidden [array] getScopeTypeandFileName($scope) {
        # return scopetype, filename

        if ($scope -match '/subscriptions/([^/]*)/resourceGroups/([^/]*)$') {
            return @([scopetype]::resourceGroup, $scope)
        }
        elseif ($scope -match '(/subscriptions/([^/]*)/resourceGroups/([^/]*))/providers/(.*)$') {
            return @([scopetype]::resource, $Matches[1])
        }
        elseif ($scope -match '/providers/Microsoft.Management/managementGroups/(.*)$') {
            return @([scopetype]::managementGroup, "/managementGroups/$($Matches[1])")
        }
        elseif ($scope -match '/subscriptions/([^/]*)$') {
            return @([scopetype]::subscription, $scope)
        }
        elseif ($scope -eq '/') {
            return @([scopetype]::root, "/root")
        }
        else {
            return @([scopetype]::unknown, $scope)
        }
    }


    hidden [System.Collections.Generic.List[RoleAssignment]] FromAzureGetAssignmentsForFile($fileName) {

        $FromAzureGetAssignmentsForFile = [System.Collections.Generic.List[RoleAssignment]]::new()
        foreach ($assignmentIndex in $this.fromAzureAssignmentsfileIndex[$fileName]) {
            $FromAzureGetAssignmentsForFile.Add($this.fromAzureAssignments[$assignmentIndex])
        }
        return $FromAzureGetAssignmentsForFile
    }

    hidden [System.Collections.Generic.List[pscustomobject]] MinimiseAssignments($assignments) {
        # to minimise json file size/compexity this removes scope if blank and scopeType (as known from file location)
        $MinimisedAssigments = [System.Collections.Generic.List[pscustomobject]]::new()
        foreach ($assignment in $assignments) {
            if ( $assignment.scope -eq "") {
                $MinimisedAssigments.Add( ($assignment | Select-Object -Property role, objectName, objectType) )
            }
            else {
                $MinimisedAssigments.Add( ($assignment | Select-Object -Property role, objectName, objectType, scope) )
            }
        }

        return $MinimisedAssigments
    }

    hidden [System.Collections.Generic.List[Tag]] FromAzureGetTagsForFile($fileName) {

        $FromAzureGetTagsForFile = [System.Collections.Generic.List[Tag]]::new()
        foreach ($tagIndex in $this.fromAzureTagsfileIndex[$fileName]) {
            $FromAzureGetTagsForFile.Add($this.fromAzureTags[$tagIndex])
        }
        return $FromAzureGetTagsForFile
    }

    [void] getData($subscriptions) {

        if ($subscriptions[0] -eq "*") {
            $this.subscriptions = $this.contexts.enumerate().subscription.name
        }
        else {
            $this.subscriptions = $subscriptions
        }

        foreach ($sub in $this.subscriptions) {
            writelog "Processing $sub"
            $context = $this.contexts.get($sub)
            if (!$context) {
                throw "Subscription not found: $sub"
            }
            $this.getRoleAssignments($context)
            $this.getResourceGroupsAndTags($context)
        }

    }

    [void] getRoleAssignments($context) {

        $result = Get-AzRoleAssignment -DefaultProfile $context -WarningAction SilentlyContinue
        foreach ($assignment in $result) {
            $this.fromAzureAssignmentAdd($assignment)
        }

        writelog " $($this.fromAzureAssignments.count) Role Assignments loaded from Azure"

    }

    hidden [void] getResourceGroupsAndTags($context) {

        $result = (Get-AzResourceGroup -DefaultProfile $context | Where-Object ManagedBy -eq $null)

        foreach ($rg in $result) {

            $rgnew = [ResourceGroup]::new()
            $rgnew.resourceGroupName = $rg.ResourceGroupName
            $rgnew.location = $rg.Location
            $rgnew.ResourceId = $rg.ResourceId

            $this.fromAzureResourceGroups.Add($rgnew.ResourceId, $rgnew)
            $this.fromAzureTagAdd($rg)
        }

        writelog " $($this.fromAzureResourceGroups.Count) Resource Groups loaded from Azure"
        writelog " $($this.fromAzureTags.count) Resource Group Tags loaded from Azure"

    }

    [void] fromAzureCreateFiles($rootFolder, $outputFormat) {

        $this.outputFormat = $outputFormat

        $filesToCreate = @{}

        # Create files for any detected assignments
        foreach ($fileName in $this.fromAzureAssignmentsfileIndex.keys) {
            $filesToCreate[$fileName] = 1
        }

        # need to also create files for resource groups that have no assignments
        foreach ($fileName in $this.FromAzureResourceGroups.Keys) {
            $filesToCreate[$fileName] = 1
        }

        # need to also create files for subscriptions that have no assignments
        Foreach ($sub in $this.subscriptions) {
            $subid = $this.Contexts.get($sub).subscription.id
            $filesToCreate["/subscriptions/$subid"] = 1
        }

        foreach ($fileName in $filesToCreate.keys | sort-object) {
            $parent = split-Path -Path $this.FilePaths.ConvertToName("$rootFolder$fileName") -Parent

            if (-not (Test-Path "$parent")) {
                mkdir "$parent"
            }

            $assignments = ($this.FromAzureGetAssignmentsForFile($fileName) |
                select-object -Property scope, scopeType, role, objectName, objectType |
                Sort-Object filename, scope, scopeType, role, objectName, objectType)

            $tags = @{}
            $tags = [ordered]@{}
            ($this.FromAzureGetTagsForFile($fileName) |
            select-object -Property name, value |
            Sort-Object filename, name, value) | ForEach-Object { $tags.Add($_.name, $_.value) }

            if ($fileName -like '*/resourceGroups/*') {

                # remove scope and scopetype from resource group assignments
                $minAssignments = $this.MinimiseAssignments($assignments)

                $rg = $this.FromAzureResourceGroups[$fileName]
                $rgFileData = [ResourceGroupFileData]::new($rg.resourceGroupName, $rg.location, $tags, $minAssignments)
                $this.writeToFile($rgFileData, "$rootFolder$fileName")
            }
            else {

                # set name of the file for subscription, managementgroup, or root
                $name = 'subscription.$($this.fileextension)'
                Switch -Wildcard ($fileName) {
                    '/root' {
                        $name = 'root'
                        break
                    }
                    '/managementGroups/*' {
                        $name = 'managementgroup'
                        break
                    }
                    '/subscriptions/*' {
                        $name = 'subscription'
                        break
                    }
                }

                # TODO
                if (-not (Test-Path $this.FilePaths.ConvertToName("$rootFolder$fileName"))) {
                    mkdir $this.FilePaths.ConvertToName("$rootFolder$fileName")
                }


                $minAssignments = $this.MinimiseAssignments($assignments)
                $FileData = [FileData]::new($tags, $minAssignments)
                $this.writeToFile($FileData, "$rootFolder$fileName/$name")
            }

        }

    }

    [string] formatJson([pscustomobject]$object) {
        return $this.formatJson([string]($object |  ConvertTo-Json -Depth 100))
    }

    [string] formatJson([string]$json) {

        $IndentStack = [system.collections.stack]::new()
        [string] $output = ""

        [bool] $inString = $false

        foreach ($line in $json -split '\n') {

            $SOLIndent = $IndentStack.Count

            # loop through each character icrementing or decrementing
            $lastChar = ''
            foreach ($i in $line.ToCharArray()) {

                if ($IndentStack.Count -gt 0 -and $IndentStack.Peek() -eq '"') {
                    $inString = $true
                }
                else {
                    $inString = $false
                }

                if ($i -eq '[' -and -not $inString) {
                    $IndentStack.Push($i)
                }
                elseif ($i -eq '{' -and -not $inString ) {
                    $IndentStack.Push($i)
                }
                elseif ($i -eq '"' -and -not $inString) {
                    $IndentStack.Push($i)
                }
                elseif ($i -eq ']' -and -not $inString) {
                    $null = $IndentStack.Pop()
                }
                elseif ($i -eq '}' -and -not $inString) {
                    $null = $IndentStack.Pop()
                }
                elseif ($i -eq '"' -and $lastChar -ne '\') {
                    $null = $IndentStack.Pop()
                }
                $lastChar = $i
            }

            $EOLIndent = $IndentStack.Count

            if ($EOLIndent -lt $SOLIndent) {
                $line = ' ' * $EOLIndent * 4 + $line.Trim().Replace(': ', ': ').Replace('\u0027', "'").Replace('\u003c', "<").Replace('\u003e', ">").Replace('\u0026', "&")
            }
            else {
                $line = ' ' * $SOLIndent * 4 + $line.Trim().Replace(': ', ': ').Replace('\u0027', "'").Replace('\u003c', "<").Replace('\u003e', ">").Replace('\u0026', "&")
            }
            if ($line.Trim() -ne "") { $output += "$line`n" }

        }
        return $output.Substring(0, $output.Length - 1)
    }

    [void] writeToFile([pscustomobject]$object, [string] $filename) {

        $jsonData = $this.formatJson($object)
        $outputFileName = $this.FilePaths.ConvertToName("$filename")

        if ($this.outputFormat -eq "YAML") {
            $yamldata = ($jsonData | ConvertFrom-Json | ConvertTo-yaml)
            writelog " Creating file ${outputFileName}.yaml" -verbose
            $yamldata | Out-File $this.FilePaths.ConvertToName("$filename.yaml") -NoNewline
        }
        else {
            writelog " Creating file ${outputFileName}.json" -verbose
            $jsonData | Out-File $this.FilePaths.ConvertToName("$filename.json") -NoNewline
        }

    }

}

<#
.SYNOPSIS
    Export role assignments, resource groups, and resource group tags from Azure to YAML or JSON files.
 
.DESCRIPTION
    Loops through subscriptions, exporting role assignments, resource groups, and resource group tags, and creates a separate YAML or JSON file for each resource group, subscription, or management group.
 
    Use connect-azaccount to logon first.
 
.PARAMETER Subscriptions
    Specifies the subscriptions to process.
    Defaults to '*'
 
.PARAMETER Path
    Specifies the path to the output folder.
    The folder will be created if it does not exist.
    Defaults to '.\output'
 
.PARAMETER Format
    Specifies the file format.
    YAML or JSON.
    Defaults to YAML.
 
.EXAMPLE
    PS> Export-AzureRoleAssignment
    Export role assignments, for all subscriptions you have access to, to YAML files.
 
.EXAMPLE
    PS> Export-AzureRoleAssignment -Format JSON
    Export role assignments, for all subscriptions you have access to, to JSON files.
 
.EXAMPLE
    PS> Export-AzureRoleAssignment -Subscriptions OSX-SUB-DEV, OSX-SUB-SIT
    Export role assignments, for subscriptions OSX-SUB-DEV and OSX-SUB-SIT, to YAML files.
 
.EXAMPLE
    PS> Export-AzureRoleAssignment -Format JSON
    Export role assignments, for all subscriptions you have access to, to JSON files.
#>

function Export-AzureRoleAssignment {
    [cmdletbinding()]
    param (
        [string[]] $Subscriptions = @("*"),
        [string] $Path = ".\output",
        [ValidateSet("JSON", "YAML")]
        [string] $Format = "YAML"
    )

    writelog "Export-AzureRoleAssignment started ($version)" -verbose

    # get required context (subscription) data
    $contexts = [Contexts]::new()

    # get role assignment data for each subscription
    $roleAssignmentData = [RoleAssignmentData]::new($contexts)
    $roleAssignmentData.getData($Subscriptions)

    writelog "Write data to file: $path"
    $roleAssignmentData.fromAzureCreateFiles($Path, $Format)

    writelog "Export-AzureRoleAssignment completed ($version)" -verbose
}


Export-ModuleMember -function Export-AzureRoleAssignment