modules/ISC.PowerPlatformConnector.psm1

#Requires -Version 5.1
Set-StrictMode -Version Latest

# -----------------------------------------------------------------------------
# Microsoft Copilot Studio agents live in Dataverse, not Azure, so the ISC connector reads them as a
# Dataverse application user rather than through Microsoft Graph. This module checks for that
# application user and creates it with the two security roles SailPoint's connector documentation
# asks for.
#
# https://documentation.sailpoint.com/connectors/saas/msentraid/help/saas_connectivity/microsoft_entra_id/copilot_studio_agents.html
# -----------------------------------------------------------------------------

$script:GlobalDiscoveryResource = 'https://globaldisco.crm.dynamics.com'
$script:DataverseApiVersion = 'v9.2'

$script:DiscoveryRoleName = 'Global Discovery Service Role'

# The built-in Bot Viewer role grants prvReadbot at Basic depth, which scopes reads to records the
# user owns. An application user owns no agents, so it authenticates successfully and then reads
# zero bots. Only organization-level (Global) depth works, which is why SailPoint documents a custom
# BotReader role; this module creates one rather than relying on a built-in role.
$script:BotReaderRoleName = 'SailPoint BotReader'
$script:BotReadPrivileges = @('prvReadbot', 'prvReadbotcomponent')

function Get-CopilotStudioRoleNames {
    return @($script:DiscoveryRoleName, $script:BotReaderRoleName)
}

function Get-BotReadPrivilegeNames {
    return @($script:BotReadPrivileges)
}

function Invoke-AzCliJson {
    param(
        [Parameter(Mandatory)][string[]]$Arguments,
        [string]$AzPath
    )

    if (-not $AzPath) { $AzPath = Ensure-AzCliCommand }
    $output = & $AzPath @Arguments 2>&1
    if ($LASTEXITCODE -ne 0) {
        throw "az $($Arguments -join ' ') failed: $(@($output) -join ' ')"
    }
    $text = (@($output) -join "`n").Trim()
    if (-not $text) { return $null }
    return $text | ConvertFrom-Json
}

# The Azure CLI mints tokens per audience, so a Dataverse call needs the environment URL as the
# resource rather than the Graph or ARM default.
function Get-DataverseAccessToken {
    param(
        [Parameter(Mandatory)][string]$Resource,
        [string]$AzPath
    )

    if (-not $AzPath) { $AzPath = Ensure-AzCliCommand }
    $result = & $AzPath 'account' 'get-access-token' '--resource' $Resource '--query' 'accessToken' '-o' 'tsv' 2>&1
    if ($LASTEXITCODE -ne 0) {
        throw ("Could not get a Dataverse token for $Resource. Run 'az login' as a Power Platform administrator first. " +
            "$(@($result) -join ' ')")
    }
    $token = (@($result) -join '').Trim()
    if (-not $token) { throw "The Azure CLI returned an empty token for $Resource." }
    return $token
}

function Invoke-DataverseRequest {
    param(
        [Parameter(Mandatory)][string]$EnvironmentUrl,
        [Parameter(Mandatory)][string]$Path,
        [string]$Method = 'GET',
        $Body,
        [Parameter(Mandatory)][string]$Token,
        [hashtable]$ExtraHeaders = @{}
    )

    $headers = @{
        Authorization    = "Bearer $Token"
        Accept           = 'application/json'
        'OData-Version'  = '4.0'
        'OData-MaxVersion' = '4.0'
    }
    foreach ($entry in $ExtraHeaders.GetEnumerator()) { $headers[$entry.Key] = $entry.Value }

    $uri = "$($EnvironmentUrl.TrimEnd('/'))/api/data/$script:DataverseApiVersion/$($Path.TrimStart('/'))"
    $parameters = @{
        Method      = $Method
        Uri         = $uri
        Headers     = $headers
        ErrorAction = 'Stop'
    }
    if ($null -ne $Body) {
        $parameters['Body'] = ($Body | ConvertTo-Json -Depth 5 -Compress)
        $parameters['ContentType'] = 'application/json'
    }

    return Invoke-RestMethod @parameters
}

function Get-PowerPlatformEnvironment {
    param([string]$AzPath)

    $token = Get-DataverseAccessToken -Resource $script:GlobalDiscoveryResource -AzPath $AzPath
    $response = Invoke-RestMethod -Method GET -ErrorAction Stop `
        -Uri "$script:GlobalDiscoveryResource/api/discovery/v2.0/Instances" `
        -Headers @{ Authorization = "Bearer $token"; Accept = 'application/json' }

    $environments = [System.Collections.Generic.List[object]]::new()
    foreach ($instance in @($response.value)) {
        $environments.Add([PSCustomObject]@{
            Name = [string]$instance.FriendlyName
            Url  = [string]$instance.ApiUrl
            Id   = [string]$instance.EnvironmentId
        })
    }
    return @($environments | Sort-Object Name)
}

function Get-DataverseApplicationUser {
    param(
        [Parameter(Mandatory)][string]$EnvironmentUrl,
        [Parameter(Mandatory)][string]$ClientId,
        [Parameter(Mandatory)][string]$Token
    )

    $response = Invoke-DataverseRequest -EnvironmentUrl $EnvironmentUrl -Token $Token `
        -Path "systemusers?`$filter=applicationid eq $ClientId&`$select=fullname,applicationid"
    return @($response.value) | Select-Object -First 1
}

function Get-DataverseRootBusinessUnit {
    param(
        [Parameter(Mandatory)][string]$EnvironmentUrl,
        [Parameter(Mandatory)][string]$Token
    )

    $response = Invoke-DataverseRequest -EnvironmentUrl $EnvironmentUrl -Token $Token `
        -Path "businessunits?`$select=name,businessunitid&`$filter=_parentbusinessunitid_value eq null"
    $unit = @($response.value) | Select-Object -First 1
    if (-not $unit) { throw "Could not find the root business unit in $EnvironmentUrl." }
    return $unit
}

function New-DataverseApplicationUser {
    param(
        [Parameter(Mandatory)][string]$EnvironmentUrl,
        [Parameter(Mandatory)][string]$ClientId,
        [Parameter(Mandatory)][string]$BusinessUnitId,
        [Parameter(Mandatory)][string]$Token
    )

    $body = [ordered]@{
        applicationid                = $ClientId
        'businessunitid@odata.bind'  = "/businessunits($BusinessUnitId)"
    }
    return Invoke-DataverseRequest -EnvironmentUrl $EnvironmentUrl -Token $Token -Method 'POST' `
        -Path 'systemusers' -Body $body -ExtraHeaders @{ Prefer = 'return=representation' }
}

function Get-DataverseSecurityRole {
    param(
        [Parameter(Mandatory)][string]$EnvironmentUrl,
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][string]$Token
    )

    $escaped = $Name.Replace("'", "''")
    $response = Invoke-DataverseRequest -EnvironmentUrl $EnvironmentUrl -Token $Token `
        -Path "roles?`$select=name,roleid&`$filter=name eq '$escaped'"
    return @($response.value) | Select-Object -First 1
}

function Get-DataverseAssignedRole {
    param(
        [Parameter(Mandatory)][string]$EnvironmentUrl,
        [Parameter(Mandatory)][string]$SystemUserId,
        [Parameter(Mandatory)][string]$Token
    )

    $response = Invoke-DataverseRequest -EnvironmentUrl $EnvironmentUrl -Token $Token `
        -Path "systemusers($SystemUserId)/systemuserroles_association?`$select=name,roleid"
    return @(@($response.value) | ForEach-Object {
        [PSCustomObject]@{ Name = [string]$_.name; RoleId = [string]$_.roleid }
    })
}

function Get-DataversePrivilege {
    param(
        [Parameter(Mandatory)][string]$EnvironmentUrl,
        [Parameter(Mandatory)][string]$Name,
        [Parameter(Mandatory)][string]$Token
    )

    $response = Invoke-DataverseRequest -EnvironmentUrl $EnvironmentUrl -Token $Token `
        -Path "privileges?`$select=name,privilegeid&`$filter=name eq '$Name'"
    $privilege = @($response.value) | Select-Object -First 1
    if (-not $privilege) { throw "The $Name privilege does not exist in $EnvironmentUrl." }
    return $privilege
}

# Depth is what separates a role that works from one that silently returns nothing, so it is read
# back rather than inferred from the role name.
function Test-DataverseGlobalBotRead {
    param(
        [Parameter(Mandatory)][string]$EnvironmentUrl,
        [Parameter(Mandatory)][string]$RoleId,
        [Parameter(Mandatory)][string]$Token
    )

    $response = Invoke-DataverseRequest -EnvironmentUrl $EnvironmentUrl -Token $Token `
        -Path "RetrieveRolePrivilegesRole(RoleId=$RoleId)"
    $granted = @($response.RolePrivileges) | Where-Object {
        $_.PrivilegeName -eq 'prvReadbot' -and $_.Depth -eq 'Global'
    }
    return [bool]$granted
}

function New-DataverseBotReaderRole {
    param(
        [Parameter(Mandatory)][string]$EnvironmentUrl,
        [Parameter(Mandatory)][string]$BusinessUnitId,
        [Parameter(Mandatory)][string]$Token,
        [string]$Name = $script:BotReaderRoleName
    )

    $role = Invoke-DataverseRequest -EnvironmentUrl $EnvironmentUrl -Token $Token -Method 'POST' `
        -Path 'roles' -ExtraHeaders @{ Prefer = 'return=representation' } `
        -Body ([ordered]@{ name = $Name; 'businessunitid@odata.bind' = "/businessunits($BusinessUnitId)" })

    $privileges = @()
    foreach ($privilegeName in Get-BotReadPrivilegeNames) {
        $privilege = Get-DataversePrivilege -EnvironmentUrl $EnvironmentUrl -Name $privilegeName -Token $Token
        $privileges += [ordered]@{
            Depth          = 'Global'
            PrivilegeId    = [string]$privilege.privilegeid
            BusinessUnitId = $BusinessUnitId
        }
    }

    Invoke-DataverseRequest -EnvironmentUrl $EnvironmentUrl -Token $Token -Method 'POST' `
        -Path "roles($($role.roleid))/Microsoft.Dynamics.CRM.AddPrivilegesRole" `
        -Body ([ordered]@{ Privileges = $privileges }) | Out-Null

    return $role
}

function Add-DataverseSecurityRole {
    param(
        [Parameter(Mandatory)][string]$EnvironmentUrl,
        [Parameter(Mandatory)][string]$SystemUserId,
        [Parameter(Mandatory)][string]$RoleId,
        [Parameter(Mandatory)][string]$Token
    )

    $reference = "$($EnvironmentUrl.TrimEnd('/'))/api/data/$script:DataverseApiVersion/roles($RoleId)"
    Invoke-DataverseRequest -EnvironmentUrl $EnvironmentUrl -Token $Token -Method 'POST' `
        -Path "systemusers($SystemUserId)/systemuserroles_association/`$ref" `
        -Body ([ordered]@{ '@odata.id' = $reference }) | Out-Null
}

# Returns what the environment needed rather than throwing, so one unreachable environment cannot
# fail a run that has already configured the application.
function Set-CopilotStudioDataverseAccess {
    param(
        [Parameter(Mandatory)][string]$EnvironmentUrl,
        [Parameter(Mandatory)][string]$ClientId,
        [string]$EnvironmentName,
        [switch]$WhatIf,
        [string]$AzPath
    )

    $label = if ($EnvironmentName) { $EnvironmentName } else { $EnvironmentUrl }
    $result = [PSCustomObject]@{
        Environment  = $label
        Url          = $EnvironmentUrl
        UserCreated  = $false
        RolesAdded   = @()
        RolesPresent = @()
        RolesMissing = @()
        Error        = $null
    }

    try {
        $token = Get-DataverseAccessToken -Resource $EnvironmentUrl -AzPath $AzPath
        $user = Get-DataverseApplicationUser -EnvironmentUrl $EnvironmentUrl -ClientId $ClientId -Token $token

        if (-not $user) {
            if ($WhatIf) {
                $result.UserCreated = $true
                $result.RolesMissing = @(Get-CopilotStudioRoleNames)
                return $result
            }
            $unit = Get-DataverseRootBusinessUnit -EnvironmentUrl $EnvironmentUrl -Token $token
            $user = New-DataverseApplicationUser -EnvironmentUrl $EnvironmentUrl -ClientId $ClientId `
                -BusinessUnitId $unit.businessunitid -Token $token
            $result.UserCreated = $true
            Write-Ok "$label : created the application user"
        }

        $assigned = @(Get-DataverseAssignedRole -EnvironmentUrl $EnvironmentUrl -SystemUserId $user.systemuserid -Token $token)
        $assignedNames = @($assigned | ForEach-Object { $_.Name })
        $added = [System.Collections.Generic.List[string]]::new()
        $missing = [System.Collections.Generic.List[string]]::new()

        if ($assignedNames -notcontains $script:DiscoveryRoleName) {
            $role = Get-DataverseSecurityRole -EnvironmentUrl $EnvironmentUrl -Name $script:DiscoveryRoleName -Token $token
            if (-not $role) {
                Write-Warning "$label : the '$script:DiscoveryRoleName' security role does not exist; assign an equivalent manually."
                $missing.Add($script:DiscoveryRoleName)
            }
            elseif ($WhatIf) { $added.Add($script:DiscoveryRoleName) }
            else {
                Add-DataverseSecurityRole -EnvironmentUrl $EnvironmentUrl -SystemUserId $user.systemuserid `
                    -RoleId $role.roleid -Token $token
                $added.Add($script:DiscoveryRoleName)
                Write-Ok "$label : assigned $script:DiscoveryRoleName"
            }
        }

        # Any already assigned role granting organization-level bot read is enough, so a hand-built
        # BotReader or Bot Contributor is honoured instead of assigning a second role.
        $hasBotRead = $false
        foreach ($role in $assigned) {
            if (Test-DataverseGlobalBotRead -EnvironmentUrl $EnvironmentUrl -RoleId $role.RoleId -Token $token) {
                $hasBotRead = $true
                $result.RolesPresent = @($result.RolesPresent) + $role.Name
                break
            }
        }

        if (-not $hasBotRead) {
            if ($WhatIf) { $added.Add($script:BotReaderRoleName) }
            else {
                $botReader = Get-DataverseSecurityRole -EnvironmentUrl $EnvironmentUrl -Name $script:BotReaderRoleName -Token $token
                if ($botReader -and -not (Test-DataverseGlobalBotRead -EnvironmentUrl $EnvironmentUrl -RoleId $botReader.roleid -Token $token)) {
                    $botReader = $null
                }
                if (-not $botReader) {
                    $unit = Get-DataverseRootBusinessUnit -EnvironmentUrl $EnvironmentUrl -Token $token
                    $botReader = New-DataverseBotReaderRole -EnvironmentUrl $EnvironmentUrl `
                        -BusinessUnitId $unit.businessunitid -Token $token
                    Write-Ok "$label : created the $script:BotReaderRoleName role with organization-level bot read"
                }
                Add-DataverseSecurityRole -EnvironmentUrl $EnvironmentUrl -SystemUserId $user.systemuserid `
                    -RoleId $botReader.roleid -Token $token
                $added.Add($script:BotReaderRoleName)
                Write-Ok "$label : assigned $script:BotReaderRoleName"
            }
        }

        $result.RolesAdded = @($added)
        $result.RolesMissing = @($missing)
        $result.RolesPresent = @(@($result.RolesPresent) + @($assignedNames | Where-Object { $_ -eq $script:DiscoveryRoleName }) | Select-Object -Unique)
        if (-not $result.UserCreated -and $added.Count -eq 0 -and $missing.Count -eq 0) {
            Write-Info "$label : already configured"
        }
    }
    catch {
        $result.Error = $_.Exception.Message
        Write-Warning "$label : $($_.Exception.Message)"
    }

    return $result
}

Export-ModuleMember -Function @(
    'Get-CopilotStudioRoleNames'
    'Get-BotReadPrivilegeNames'
    'Get-DataversePrivilege'
    'Test-DataverseGlobalBotRead'
    'New-DataverseBotReaderRole'
    'Get-DataverseAccessToken'
    'Invoke-DataverseRequest'
    'Get-PowerPlatformEnvironment'
    'Get-DataverseApplicationUser'
    'Get-DataverseRootBusinessUnit'
    'New-DataverseApplicationUser'
    'Get-DataverseSecurityRole'
    'Get-DataverseAssignedRole'
    'Add-DataverseSecurityRole'
    'Set-CopilotStudioDataverseAccess'
)