Private/Set-ExchangeRBACWrite.ps1

function Format-RBACCmdletPreview {
    <#
    .SYNOPSIS
    Format a cmdlet name + parameters into a copy-pastable PowerShell string.
    Used by the GUI's dry-run mode to preview what a write action would run.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [string]$Cmdlet,
        [Parameter()]                   [hashtable]$Parameters = @{}
    )

    $parts = @($Cmdlet)
    foreach ($key in ($Parameters.Keys | Sort-Object)) {
        $value = $Parameters[$key]
        if ($null -eq $value) { continue }
        if ($value -is [bool]) {
            if ($value) { $parts += "-$key" }
            continue
        }
        if ($value -is [System.Collections.IEnumerable] -and -not ($value -is [string])) {
            $items = @($value | ForEach-Object {
                $s = "$_".Replace("'", "''")
                "'$s'"
            })
            $parts += "-$key $($items -join ',')"
            continue
        }
        $s = "$value"
        if ($s -match "[`'`"\s\$]") {
            $s = $s.Replace("'", "''")
            $parts += "-$key '$s'"
        }
        else {
            $parts += "-$key $s"
        }
    }
    return ($parts -join ' ')
}

function Invoke-RBACWrite {
    <#
    .SYNOPSIS
    Internal wrapper that either previews a write action (-DryRun) or executes it.
    Returns @{ Preview = '<cmdlet>'; Result = <object> ; Executed = $bool ; Error = <obj?> }.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [string]$Cmdlet,
        [Parameter()]                   [hashtable]$Parameters = @{},
        [Parameter()]                   [switch]$DryRun
    )
    $preview = Format-RBACCmdletPreview -Cmdlet $Cmdlet -Parameters $Parameters
    if ($DryRun) {
        return [pscustomobject]@{
            Preview  = $preview
            Result   = $null
            Executed = $false
            Error    = $null
        }
    }
    try {
        $result = & $Cmdlet @Parameters -ErrorAction Stop
        return [pscustomobject]@{
            Preview  = $preview
            Result   = $result
            Executed = $true
            Error    = $null
        }
    }
    catch {
        return [pscustomobject]@{
            Preview  = $preview
            Result   = $null
            Executed = $false
            Error    = $_
        }
    }
}

function New-RBACRoleGroup {
    <#
    .SYNOPSIS
    Create a new Exchange Online role group.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [string]$Name,
        [Parameter()]                   [string]$Description,
        [Parameter()]                   [string[]]$Roles,
        [Parameter()]                   [string[]]$Members,
        [Parameter()]                   [switch]$DryRun
    )
    $params = @{ Name = $Name }
    if ($PSBoundParameters.ContainsKey('Description') -and $Description) { $params.Description = $Description }
    if ($Roles   -and $Roles.Count   -gt 0) { $params.Roles   = $Roles }
    if ($Members -and $Members.Count -gt 0) { $params.Members = $Members }
    return (Invoke-RBACWrite -Cmdlet 'New-RoleGroup' -Parameters $params -DryRun:$DryRun)
}

function Set-RBACRoleGroup {
    <#
    .SYNOPSIS
    Update description and (optionally) members of an existing role group.
    The group's Name acts as the identity in Exchange Online and cannot be renamed
    via Set-RoleGroup, only its DisplayName/Description/membership can change.
    Pass -Members to overwrite the membership list (empty array clears it).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [string]$Identity,
        [Parameter()]                   [string]$Description,
        [Parameter()]                   [string]$DisplayName,
        [Parameter()]                   [string[]]$Members,
        [Parameter()]                   [switch]$DryRun
    )
    $results = New-Object System.Collections.Generic.List[object]

    $setParams = @{ Identity = $Identity }
    if ($PSBoundParameters.ContainsKey('Description'))  { $setParams.Description = $Description }
    if ($PSBoundParameters.ContainsKey('DisplayName') -and $DisplayName) { $setParams.DisplayName = $DisplayName }
    if ($setParams.Count -gt 1) {
        $results.Add((Invoke-RBACWrite -Cmdlet 'Set-RoleGroup' -Parameters $setParams -DryRun:$DryRun))
    }

    if ($PSBoundParameters.ContainsKey('Members')) {
        $memberParams = @{ Identity = $Identity; Members = $Members; Confirm = $false }
        $results.Add((Invoke-RBACWrite -Cmdlet 'Update-RoleGroupMember' -Parameters $memberParams -DryRun:$DryRun))
    }

    return ,@($results.ToArray())
}

function Remove-RBACRoleGroup {
    <#
    .SYNOPSIS
    Delete an Exchange Online role group. Built-in groups cannot be removed; the
    GUI is responsible for guarding against that before calling.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [string]$Identity,
        [Parameter()]                   [switch]$DryRun
    )
    $params = @{ Identity = $Identity; Confirm = $false }
    return (Invoke-RBACWrite -Cmdlet 'Remove-RoleGroup' -Parameters $params -DryRun:$DryRun)
}

function New-RBACRole {
    <#
    .SYNOPSIS
    Create a custom Management Role as a child of an existing role.
    Exchange roles are normally created by copying a parent (built-in or custom)
    and then trimming role entries from the child.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [string]$Name,
        [Parameter(Mandatory = $true)] [string]$Parent,
        [Parameter()]                   [string]$Description,
        [Parameter()]                   [switch]$DryRun
    )
    $params = @{ Name = $Name; Parent = $Parent }
    if ($Description) { $params.Description = $Description }
    return (Invoke-RBACWrite -Cmdlet 'New-ManagementRole' -Parameters $params -DryRun:$DryRun)
}

function Set-RBACRole {
    <#
    .SYNOPSIS
    Update the description of a custom management role. Built-in roles cannot
    be modified; the GUI guards against that before calling.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [string]$Identity,
        [Parameter()]                   [string]$Description,
        [Parameter()]                   [switch]$DryRun
    )
    $params = @{ Identity = $Identity }
    if ($PSBoundParameters.ContainsKey('Description')) { $params.Description = $Description }
    return (Invoke-RBACWrite -Cmdlet 'Set-ManagementRole' -Parameters $params -DryRun:$DryRun)
}

function Remove-RBACRole {
    <#
    .SYNOPSIS
    Delete a custom management role. Built-in roles cannot be removed.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [string]$Identity,
        [Parameter()]                   [switch]$DryRun
    )
    $params = @{ Identity = $Identity; Confirm = $false }
    return (Invoke-RBACWrite -Cmdlet 'Remove-ManagementRole' -Parameters $params -DryRun:$DryRun)
}

function Add-RBACRoleEntry {
    <#
    .SYNOPSIS
    Add a cmdlet (Management Role Entry) to a custom management role.
    Identity must be in the form 'RoleName\CmdletName'.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [string]$Identity,
        [Parameter()]                   [switch]$DryRun
    )
    $params = @{ Identity = $Identity }
    return (Invoke-RBACWrite -Cmdlet 'Add-ManagementRoleEntry' -Parameters $params -DryRun:$DryRun)
}

function Remove-RBACRoleEntry {
    <#
    .SYNOPSIS
    Remove a cmdlet (Management Role Entry) from a custom management role.
    Identity must be in the form 'RoleName\CmdletName'.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [string]$Identity,
        [Parameter()]                   [switch]$DryRun
    )
    $params = @{ Identity = $Identity; Confirm = $false }
    return (Invoke-RBACWrite -Cmdlet 'Remove-ManagementRoleEntry' -Parameters $params -DryRun:$DryRun)
}

function New-RBACAssignment {
    <#
    .SYNOPSIS
    Create a new management role assignment binding a role to an assignee.
    Exactly one of -SecurityGroup, -User, -Computer, -Policy, -App must be set.
    Optional recipient scoping is exposed via -RecipientOrganizationalUnitScope or
    -CustomRecipientWriteScope (mutually exclusive on Exchange's side).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [string]$Name,
        [Parameter(Mandatory = $true)] [string]$Role,
        [Parameter()]                   [ValidateSet('SecurityGroup','User','Computer','Policy','App')]
                                        [string]$AssigneeKind,
        [Parameter()]                   [string]$Assignee,
        [Parameter()]                   [string]$RecipientOrganizationalUnitScope,
        [Parameter()]                   [string]$CustomRecipientWriteScope,
        [Parameter()]                   [switch]$DryRun
    )
    if (-not $AssigneeKind -or -not $Assignee) {
        return [pscustomobject]@{
            Preview  = ''
            Result   = $null
            Executed = $false
            Error    = [System.Management.Automation.ErrorRecord]::new(
                          [System.ArgumentException]::new('Assignee kind and value are required.'),
                          'AssigneeMissing',
                          [System.Management.Automation.ErrorCategory]::InvalidArgument,
                          $null)
        }
    }
    $params = @{ Name = $Name; Role = $Role; $AssigneeKind = $Assignee }
    if ($RecipientOrganizationalUnitScope) { $params.RecipientOrganizationalUnitScope = $RecipientOrganizationalUnitScope }
    if ($CustomRecipientWriteScope)        { $params.CustomRecipientWriteScope        = $CustomRecipientWriteScope }
    return (Invoke-RBACWrite -Cmdlet 'New-ManagementRoleAssignment' -Parameters $params -DryRun:$DryRun)
}

function Remove-RBACAssignment {
    <#
    .SYNOPSIS
    Delete a management role assignment. Built-in delegating assignments
    (Identity ends with '-Delegating' and similar) often refuse removal; the
    GUI surfaces the underlying error if the cmdlet refuses.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [string]$Identity,
        [Parameter()]                   [switch]$DryRun
    )
    $params = @{ Identity = $Identity; Confirm = $false }
    return (Invoke-RBACWrite -Cmdlet 'Remove-ManagementRoleAssignment' -Parameters $params -DryRun:$DryRun)
}

function New-RBACScope {
    <#
    .SYNOPSIS
    Create a recipient management scope. Pass -RecipientRoot for an OU-scoped
    scope and/or -RecipientRestrictionFilter for an OPATH filter.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [string]$Name,
        [Parameter()]                   [string]$RecipientRoot,
        [Parameter()]                   [string]$RecipientRestrictionFilter,
        [Parameter()]                   [switch]$DryRun
    )
    if (-not $RecipientRoot -and -not $RecipientRestrictionFilter) {
        return [pscustomobject]@{
            Preview  = ''
            Result   = $null
            Executed = $false
            Error    = [System.Management.Automation.ErrorRecord]::new(
                          [System.ArgumentException]::new('Provide RecipientRoot, RecipientRestrictionFilter, or both.'),
                          'ScopeFilterMissing',
                          [System.Management.Automation.ErrorCategory]::InvalidArgument,
                          $null)
        }
    }
    $params = @{ Name = $Name }
    if ($RecipientRoot)              { $params.RecipientRoot              = $RecipientRoot }
    if ($RecipientRestrictionFilter) { $params.RecipientRestrictionFilter = $RecipientRestrictionFilter }
    return (Invoke-RBACWrite -Cmdlet 'New-ManagementScope' -Parameters $params -DryRun:$DryRun)
}

function Set-RBACScope {
    <#
    .SYNOPSIS
    Update an existing recipient management scope (filter, root, name).
    Pass -NewName to rename. Built-in implicit scopes cannot be modified.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [string]$Identity,
        [Parameter()]                   [string]$NewName,
        [Parameter()]                   [string]$RecipientRoot,
        [Parameter()]                   [string]$RecipientRestrictionFilter,
        [Parameter()]                   [switch]$DryRun
    )
    $params = @{ Identity = $Identity }
    if ($PSBoundParameters.ContainsKey('NewName') -and $NewName) { $params.Name = $NewName }
    if ($PSBoundParameters.ContainsKey('RecipientRoot'))         { $params.RecipientRoot = $RecipientRoot }
    if ($PSBoundParameters.ContainsKey('RecipientRestrictionFilter')) {
        $params.RecipientRestrictionFilter = $RecipientRestrictionFilter
    }
    return (Invoke-RBACWrite -Cmdlet 'Set-ManagementScope' -Parameters $params -DryRun:$DryRun)
}

function Remove-RBACScope {
    <#
    .SYNOPSIS
    Delete a custom management scope. Implicit scopes cannot be removed.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [string]$Identity,
        [Parameter()]                   [switch]$DryRun
    )
    $params = @{ Identity = $Identity; Confirm = $false }
    return (Invoke-RBACWrite -Cmdlet 'Remove-ManagementScope' -Parameters $params -DryRun:$DryRun)
}

function Copy-RBACRoleGroup {
    <#
    .SYNOPSIS
    Duplicate an existing role group: same Roles list, optionally same Members,
    under a new Name. Uses New-RoleGroup with the source's roles array.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)] [string]$SourceName,
        [Parameter(Mandatory = $true)] [string]$NewName,
        [Parameter()]                   [string]$NewDescription,
        [Parameter()]                   [switch]$IncludeMembers,
        [Parameter()]                   [switch]$DryRun
    )
    try {
        $source = Get-RoleGroup -Identity $SourceName -ErrorAction Stop
    }
    catch {
        return [pscustomobject]@{
            Preview  = ''
            Result   = $null
            Executed = $false
            Error    = $_
        }
    }

    $sourceRoles   = @($source.Roles   | ForEach-Object { "$_" } | Where-Object { $_ })
    $sourceMembers = @($source.Members | ForEach-Object { "$_" } | Where-Object { $_ })

    $params = @{ Name = $NewName }
    if ($NewDescription) { $params.Description = $NewDescription }
    elseif ($source.Description) { $params.Description = "$($source.Description) (copy of $SourceName)" }
    if ($sourceRoles.Count -gt 0) { $params.Roles = $sourceRoles }
    if ($IncludeMembers -and $sourceMembers.Count -gt 0) { $params.Members = $sourceMembers }

    return (Invoke-RBACWrite -Cmdlet 'New-RoleGroup' -Parameters $params -DryRun:$DryRun)
}