Private/Resolve-EntraOpsServiceEMDelegationGroup.ps1

<#
.SYNOPSIS
    Resolves or creates a role-assignable delegation group for ServiceEM landing zones.

.DESCRIPTION
    Looks up the delegation group by config ID, then by default name (only role-assignable
    security groups are accepted by name). If neither exists,
    attempts to create a role-assignable group (requires RoleManagement.ReadWrite.Directory
    scope). Persists the group ID back into EntraOpsConfig.json and the global variable
    to avoid duplicate creation on subsequent runs.

    A supplied group ID that isn't found (HTTP 404) falls back to the name-based lookup with a warning, but
    is never replaced: no group is created and the configuration isn't changed. A supplied ID that isn't a
    valid object ID, other lookup errors, and more than one role-assignable group with the default name
    stop with an error.

.PARAMETER Plane
    The plane to resolve: ControlPlane or ManagementPlane.

.PARAMETER GroupId
    Existing group ID from parameter or config. When non-empty the group is looked up and returned.

.PARAMETER DefaultGroupName
    Default display name used to search for an existing group by name.

.PARAMETER ConfigKey
    Key inside ServiceEM config to persist the group ID (e.g., ControlPlaneDelegationGroupId).

.PARAMETER ConfigFilePath
    Path to EntraOpsConfig.json for persistence. Defaults to EntraOpsConfig.json in the current folder
    when it exists, otherwise in the EntraOps working folder ($EntraOpsBaseFolder).

.PARAMETER logPrefix
    Log prefix for verbose output.

.OUTPUTS
    System.String — the Object ID of the resolved or created group.
#>

function Resolve-EntraOpsServiceEMDelegationGroup {
    [OutputType([string])]
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateSet("ControlPlane", "ManagementPlane")]
        [string]$Plane,

        [string]$GroupId = "",

        [Parameter(Mandatory)]
        [string]$DefaultGroupName,

        [Parameter(Mandatory)]
        [string]$ConfigKey,

        # Like Connect-EntraOps: the current folder's EntraOpsConfig.json, otherwise the one in the working folder
        [string]$ConfigFilePath = $(if ((Test-Path -LiteralPath "$PWD/EntraOpsConfig.json" -PathType Leaf) -or [string]::IsNullOrWhiteSpace($Global:EntraOpsBaseFolder)) { "$PWD/EntraOpsConfig.json" } else { Join-Path $Global:EntraOpsBaseFolder 'EntraOpsConfig.json' }),

        [string]$logPrefix = "[Resolve-EntraOpsServiceEMDelegationGroup]"
    )

    # 1. If a GroupId was supplied (parameter or config), validate it and return.
    # Only a confirmed "not found" falls back to the name-based lookup; a malformed ID or any other error (e.g. 403, throttling) stops.
    $configuredGroupMissing = $false
    if (-not [string]::IsNullOrWhiteSpace($GroupId)) {
        Write-Verbose "$logPrefix $Plane delegation group ID supplied: $GroupId — validating"
        if ($GroupId -notmatch '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$') {
            throw "$logPrefix The $Plane delegation group ID '$GroupId' (ServiceEM.$ConfigKey or parameter) is not a valid object ID. Set the Object ID of a role-assignable security group, or remove the value to look up the group by name."
        }
        $existing = $null
        try {
            $existing = Invoke-EntraOpsMsGraphQuery -Method GET -Uri "/v1.0/groups/$($GroupId)?`$select=id,displayName,isAssignableToRole,securityEnabled" -OutputType PSObject -DisableCache -ThrowOnFailure -SuppressNotFoundWarning
        } catch {
            if ($_.Exception.Data['StatusCode'] -ne 404) {
                throw "$logPrefix Failed to look up the $Plane delegation group '$GroupId' (ServiceEM.$ConfigKey): $($_.Exception.Message)"
            }
            $existing = $null
        }
        if ($existing.Id) {
            # Membership of a group that isn't role-assignable can be managed by less privileged administrators
            if (-not ($existing.isAssignableToRole -eq $true -and $existing.securityEnabled -eq $true)) {
                throw "$Plane delegation group '$($existing.DisplayName)' ($($existing.Id)) isn't a role-assignable security group. Use a role-assignable security group for ServiceEM.${Plane}DelegationGroupId."
            }
            Write-Verbose "$logPrefix Validated $Plane group: $($existing.DisplayName) ($($existing.Id))"
            return $existing.Id
        }
        $configuredGroupMissing = $true
        Write-Warning "$logPrefix Supplied $Plane group ID '$GroupId' was not found. Looking for an existing role-assignable group named '$DefaultGroupName'."
    }

    # 2. Search by default display name.
    Write-Verbose "$logPrefix Searching for existing $Plane group by name: '$DefaultGroupName'"
    try {
        $encodedDefaultGroupName = ConvertTo-EntraOpsODataStringLiteral -Value $DefaultGroupName
        $candidates = @(Invoke-EntraOpsMsGraphQuery -Method GET -Uri "/v1.0/groups?`$filter=displayName eq '$encodedDefaultGroupName'&`$select=id,displayName,isAssignableToRole,securityEnabled" -OutputType PSObject -DisableCache -ThrowOnFailure | Where-Object { $_.Id })
    } catch {
        throw "$logPrefix Failed to look up the $Plane delegation group by name '$DefaultGroupName': $($_.Exception.Message)"
    }
    # Display names are not unique: only accept role-assignable security groups to avoid binding to a look-alike group
    $found = @($candidates | Where-Object { $_.isAssignableToRole -eq $true -and $_.securityEnabled -eq $true })
    if ($candidates.Count -gt $found.Count) {
        Write-Warning "$logPrefix Ignored $($candidates.Count - $found.Count) group(s) named '$DefaultGroupName' that are not role-assignable security groups."
    }

    if ($found.Count -gt 1) {
        throw "$logPrefix Found $($found.Count) role-assignable groups named '$DefaultGroupName' ($(@($found.Id) -join ', ')). Set the Object ID of the intended $Plane delegation group in EntraOpsConfig.json under ServiceEM.$ConfigKey (or pass it as parameter) and re-run."
    }

    if ($found.Count -eq 1) {
        $resolvedId = $found[0].Id
        if ($configuredGroupMissing) {
            # The configured ID is kept so the operator can confirm the substitute group by updating it; this run uses the substitute
            Write-Warning "$logPrefix Using the existing role-assignable group '$DefaultGroupName' ($resolvedId) instead of the configured $Plane group '$GroupId', which wasn't found. Verify this group and update ServiceEM.$ConfigKey in EntraOpsConfig.json (or the parameter) to its ID."
            return $resolvedId
        }
        Write-Verbose "$logPrefix Found existing $Plane group '$DefaultGroupName' ($resolvedId)"
        # Persist to config
        Save-EntraOpsServiceEMConfigKey -ConfigKey $ConfigKey -Value $resolvedId -ConfigFilePath $ConfigFilePath -logPrefix $logPrefix
        return $resolvedId
    }

    if ($configuredGroupMissing) {
        throw "$logPrefix The $Plane delegation group '$GroupId' (ServiceEM.$ConfigKey or parameter) was not found and no role-assignable group named '$DefaultGroupName' exists. No new group is created to replace a configured one: restore the group or set the Object ID of an existing role-assignable group in ServiceEM.$ConfigKey, then re-run."
    }

    # 3. Attempt to create a role-assignable group.
    Write-Verbose "$logPrefix No existing $Plane group found. Checking permissions to create a role-assignable group."

    # Check for RoleManagement.ReadWrite.Directory scope which is required for IsAssignableToRole groups.
    $mgContext = Get-MgContext
    $requiredScope = "RoleManagement.ReadWrite.Directory"
    if ($mgContext.Scopes -notcontains $requiredScope) {
        $errorMsg = @(
            "CANNOT AUTO-CREATE ROLE-ASSIGNABLE DELEGATION GROUP FOR $Plane",
            "",
            "REASON:",
            " The connected identity does not have the required Microsoft Graph permission scope '$requiredScope'.",
            "",
            "OPTIONS TO RESOLVE:",
            "",
            " Option A - Create group manually (Recommended for production):",
            " 1. Create a security group with these properties:",
            " - DisplayName: $DefaultGroupName",
            " - IsAssignableToRole: true",
            " - SecurityEnabled: true",
            " - MailEnabled: false",
            " 2. Add the group ObjectId to EntraOpsConfig.json under ServiceEM.$ConfigKey",
            " 3. Re-run the landing zone cmdlet",
            "",
            " Option B - Switch to PerService governance model (No pre-existing groups required):",
            " - Set GovernanceModel to 'PerService' in EntraOpsConfig.json or pass -GovernanceModel PerService, OR",
            " - Pass -AllowPerServiceFallback to New-EntraOpsSubscriptionLandingZone to create per-service groups when Centralized groups are not found",
            "",
            " Option C - Grant permissions (for dev/test only):",
            " - Grant RoleManagement.ReadWrite.Directory to the calling identity",
            " - This allows auto-creation of delegation groups",
            "",
            "DOCUMENTATION:",
            " See ServiceEM.md for detailed setup instructions."
        ) -join "`n"
        throw $errorMsg
    }

    Write-Verbose "$logPrefix Creating role-assignable $Plane group: '$DefaultGroupName'"
    try {
        $newGroupParams = @{
            displayName         = $DefaultGroupName
            description         = "Tenant-wide $Plane delegation group for ServiceEM landing zones (role-assignable)"
            mailNickname        = ($DefaultGroupName -replace '[^a-zA-Z0-9]', '')
            securityEnabled     = $true
            mailEnabled         = $false
            isAssignableToRole  = $true
        }
        $newGroup = Invoke-EntraOpsMsGraphQuery -Method POST -Uri "/v1.0/groups" -Body ($newGroupParams | ConvertTo-Json -Depth 5) -OutputType PSObject -ThrowOnFailure
        if (-not $newGroup.Id) {
            throw "Group creation returned no object ID."
        }
        Write-Verbose "$logPrefix Created role-assignable $Plane group '$DefaultGroupName' ($($newGroup.Id))"
    } catch {
        $errorMsg = @(
            "Failed to create role-assignable delegation group for $Plane.",
            "Error: $_",
            "",
            "For security, this group should be created manually:",
            "",
            " Suggested group name: $DefaultGroupName",
            " IsAssignableToRole: true",
            " SecurityEnabled: true",
            " MailEnabled: false",
            "",
            "Set its Object ID in EntraOpsConfig.json under ServiceEM.$ConfigKey, then re-run."
        ) -join "`n"
        throw $errorMsg
    }

    # Persist to config
    Save-EntraOpsServiceEMConfigKey -ConfigKey $ConfigKey -Value $newGroup.Id -ConfigFilePath $ConfigFilePath -logPrefix $logPrefix

    return $newGroup.Id
}

<#
.SYNOPSIS
    Persists a ServiceEM config key to EntraOpsConfig.json and the global variable.
#>

function Save-EntraOpsServiceEMConfigKey {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$ConfigKey,

        [Parameter(Mandatory)]
        [string]$Value,

        [string]$ConfigFilePath = "$PWD/EntraOpsConfig.json",

        [string]$logPrefix = "[Save-EntraOpsServiceEMConfigKey]"
    )

    # Update global variable
    if ($null -ne $Global:EntraOpsConfig) {
        if (-not $Global:EntraOpsConfig.ContainsKey('ServiceEM')) {
            $Global:EntraOpsConfig['ServiceEM'] = @{}
        }
        $Global:EntraOpsConfig.ServiceEM[$ConfigKey] = $Value
        Write-Verbose "$logPrefix Updated Global:EntraOpsConfig.ServiceEM.$ConfigKey = $Value"
    }

    # Update config file on disk
    if (Test-Path $ConfigFilePath) {
        try {
            $configJson = Get-Content -Path $ConfigFilePath -Raw | ConvertFrom-Json -Depth 10 -AsHashtable
            if (-not $configJson.ContainsKey('ServiceEM')) {
                $configJson['ServiceEM'] = [ordered]@{
                    ControlPlaneDelegationGroupId    = ""
                    ManagementPlaneDelegationGroupId = ""
                    AdministratorGroupId             = ""
                }
            }
            $configJson.ServiceEM[$ConfigKey] = $Value
            $configJson | ConvertTo-Json -Depth 10 | Set-Content -Path $ConfigFilePath -Encoding UTF8
            Write-Verbose "$logPrefix Persisted ServiceEM.$ConfigKey to $ConfigFilePath"
        } catch {
            Write-Warning "$logPrefix Failed to persist $ConfigKey to config file: $_"
        }
    }
}

# SIG # Begin signature block
# MIIniAYJKoZIhvcNAQcCoIIneTCCJ3UCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCDelMeleE+iKNmI
# W5zBtl7SMBJ3mka3VmboMXo2Ly7M8aCCIDwwggXJMIIEsaADAgECAhAbtY8lKt8j
# AEkoya49fu0nMA0GCSqGSIb3DQEBDAUAMH4xCzAJBgNVBAYTAlBMMSIwIAYDVQQK
# ExlVbml6ZXRvIFRlY2hub2xvZ2llcyBTLkEuMScwJQYDVQQLEx5DZXJ0dW0gQ2Vy
# dGlmaWNhdGlvbiBBdXRob3JpdHkxIjAgBgNVBAMTGUNlcnR1bSBUcnVzdGVkIE5l
# dHdvcmsgQ0EwHhcNMjEwNTMxMDY0MzA2WhcNMjkwOTE3MDY0MzA2WjCBgDELMAkG
# A1UEBhMCUEwxIjAgBgNVBAoTGVVuaXpldG8gVGVjaG5vbG9naWVzIFMuQS4xJzAl
# BgNVBAsTHkNlcnR1bSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEkMCIGA1UEAxMb
# Q2VydHVtIFRydXN0ZWQgTmV0d29yayBDQSAyMIICIjANBgkqhkiG9w0BAQEFAAOC
# Ag8AMIICCgKCAgEAvfl4+ObVgAxknYYblmRnPyI6HnUBfe/7XGeMycxca6mR5rlC
# 5SBLm9qbe7mZXdmbgEvXhEArJ9PoujC7Pgkap0mV7ytAJMKXx6fumyXvqAoAl4Va
# qp3cKcniNQfrcE1K1sGzVrihQTib0fsxf4/gX+GxPw+OFklg1waNGPmqJhCrKtPQ
# 0WeNG0a+RzDVLnLRxWPa52N5RH5LYySJhi40PylMUosqp8DikSiJucBb+R3Z5yet
# /5oCl8HGUJKbAiy9qbk0WQq/hEr/3/6zn+vZnuCYI+yma3cWKtvMrTscpIfcRnNe
# GWJoRVfkkIJCu0LW8GHgwaM9ZqNd9BjuiMmNF0UpmTJ1AjHuKSbIawLmtWJFfzcV
# WiNoidQ+3k4nsPBADLxNF8tNorMe0AZa3faTz1d1mfX6hhpneLO/lv403L3nUlbl
# s+V1e9dBkQXcXWnjlQ1DufyDljmVe2yAWk8TcsbXfSl6RLpSpCrVQUYJIP4ioLZb
# MI28iQzV13D4h1L92u+sUS4Hs07+0AnacO+Y+lbmbdu1V0vc5SwlFcieLnhO+Nqc
# noYsylfzGuXIkosagpZ6w7xQEmnYDlpGizrrJvojybawgb5CAKT41v4wLsfSRvbl
# jnX98sy50IdbzAYQYLuDNbdeZ95H7JlI8aShFf6tjGKOOVVPORa5sWOd/7cCAwEA
# AaOCAT4wggE6MA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFLahVDkCw6A/joq8
# +tT4HKbROg79MB8GA1UdIwQYMBaAFAh2zcsH/yT2xc3tu5C84oQ3RnX3MA4GA1Ud
# DwEB/wQEAwIBBjAvBgNVHR8EKDAmMCSgIqAghh5odHRwOi8vY3JsLmNlcnR1bS5w
# bC9jdG5jYS5jcmwwawYIKwYBBQUHAQEEXzBdMCgGCCsGAQUFBzABhhxodHRwOi8v
# c3ViY2Eub2NzcC1jZXJ0dW0uY29tMDEGCCsGAQUFBzAChiVodHRwOi8vcmVwb3Np
# dG9yeS5jZXJ0dW0ucGwvY3RuY2EuY2VyMDkGA1UdIAQyMDAwLgYEVR0gADAmMCQG
# CCsGAQUFBwIBFhhodHRwOi8vd3d3LmNlcnR1bS5wbC9DUFMwDQYJKoZIhvcNAQEM
# BQADggEBAFHCoVgWIhCL/IYx1MIy01z4S6Ivaj5N+KsIHu3V6PrnCA3st8YeDrJ1
# BXqxC/rXdGoABh+kzqrya33YEcARCNQOTWHFOqj6seHjmOriY/1B9ZN9DbxdkjuR
# mmW60F9MvkyNaAMQFtXx0ASKhTP5N+dbLiZpQjy6zbzUeulNndrnQ/tjUoCFBMQl
# lVXwfqefAcVbKPjgzoZwpic7Ofs4LphTZSJ1Ldf23SIikZbr3WjtP6MZl9M7JYjs
# NhI9qX7OAo0FmpKnJ25FspxihjcNpDOO16hO0EoXQ0zF8ads0h5YbBRRfopUofbv
# n3l6XYGaFpAP4bvxSgD5+d2+7arszgowggZrMIIEU6ADAgECAhBX5gfP8LabvnxC
# XoItEBa/MA0GCSqGSIb3DQEBCwUAMFYxCzAJBgNVBAYTAlBMMSEwHwYDVQQKExhB
# c3NlY28gRGF0YSBTeXN0ZW1zIFMuQS4xJDAiBgNVBAMTG0NlcnR1bSBDb2RlIFNp
# Z25pbmcgMjAyMSBDQTAeFw0yNjEwMDYwODI4MjNaFw0yNzEwMDYwODI4MjJaMIGQ
# MQswCQYDVQQGEwJERTEdMBsGA1UECAwUUmhpbmVsYW5kLVBhbGF0aW5hdGUxEjAQ
# BgNVBAcMCUxhaG5zdGVpbjEeMBwGA1UECgwVT3BlbiBTb3VyY2UgRGV2ZWxvcGVy
# MS4wLAYDVQQDDCVPcGVuIFNvdXJjZSBEZXZlbG9wZXIgVGhvbWFzIE5hdW5oZWlt
# MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAphp2ljSmVsRB6gvkEq7A
# DUMENpKZiDFoAfF1ktD1nCwaVyI+bJ0ROjHwAn4iJgS+vlBDPOUIw2GLHkiqUhOg
# HSwzg6gj45GNmt6zxsKP0zPlhMB/fb0zmz7/F7e3yfKCXLfhbHuk0Es9gJ+8Wmet
# 9StVwwSE/K0MpW06GKA9nPWyE4N/ADwigDIj9rsTKJR4cve9w8G1b3zohpUc/qMr
# onab+HVw5r9b3sOBnnaXC6zvrX4fovRz1ztwfWjRjILLoFG6mW/TuK5F2ICD3Rhl
# 9R2rJWWWhzTDMsQlzvX+/SaM74q3OFpIVNdhvtmWU9pKUBRAyYMijcDqKEFGSzrc
# 8JFmGMpP5TuroWF+N21d3px2eARvW1mV0DCfFAH+Og20SH/gVW6/ltQQEkH/z/Zt
# 49ARyJrgESLOQtlMCIYSoKISdkk4ml4VUS1E/PcbzXEYj3a7m6DOxcd3WuY03UT9
# cnk3p0LMN6LeLYAZgVY+HRloZlH+kW2SkOHNuWgtXvZhAgMBAAGjggF4MIIBdDAM
# BgNVHRMBAf8EAjAAMD0GA1UdHwQ2MDQwMqAwoC6GLGh0dHA6Ly9jY3NjYTIwMjEu
# Y3JsLmNlcnR1bS5wbC9jY3NjYTIwMjEuY3JsMHMGCCsGAQUFBwEBBGcwZTAsBggr
# BgEFBQcwAYYgaHR0cDovL2Njc2NhMjAyMS5vY3NwLWNlcnR1bS5jb20wNQYIKwYB
# BQUHMAKGKWh0dHA6Ly9yZXBvc2l0b3J5LmNlcnR1bS5wbC9jY3NjYTIwMjEuY2Vy
# MB8GA1UdIwQYMBaAFN10XUwA23ufoHTKsW73PMAywHDNMB0GA1UdDgQWBBTBvGyH
# MXj1dMbVnxuof9oP4PY7cDBLBgNVHSAERDBCMAgGBmeBDAEEATA2BgsqhGgBhvZ3
# AgUBBDAnMCUGCCsGAQUFBwIBFhlodHRwczovL3d3dy5jZXJ0dW0ucGwvQ1BTMBMG
# A1UdJQQMMAoGCCsGAQUFBwMDMA4GA1UdDwEB/wQEAwIHgDANBgkqhkiG9w0BAQsF
# AAOCAgEAkC165c7/r6y3VeEEpNXas9bQs6ZpKPdJffHkf9xoPi4kWvAIWGc4ocG1
# DJLb18RXiDYShDmT5sb/6I08FeipKi2DNJkuOq6yOTJ3S7+hNFIZCyd6i7WKO8ea
# P5I+FAbyJP4+5coZoMg45bUa9JoocaJCm+5n7WiCfN7/EQpClW5Trm4+ZMbp7EMN
# F1uLJgGCMnN0SsoUarp7PHlqupMxMC4Uhhe+lAP2ytcSIU81L97/qjrADbLWqICk
# 9il5iGM+aZndguq/cmfH9rJj4GRZHWnG6WwnzYD9+KgYlRI6em+/9dq+IRiXe2Bs
# thUuIaFW7U2gWw8tBTEYm1UBLHdJx+L1gjJCLs5KT/wy5wU32wZxNDqPUQejUC1x
# GvU3cKqmLgRq+yNf3gFVfzqIoggyM3U9wQvnk7nBtCkpjXS2hdx4yxzcmrlWsDHr
# z6+6/jeyQySzbYUlFmKnc4n9Mw2soHOajEua5poFARVRS5KgUBuiiQ21YFNYzWBo
# qGuECaaAI/C5rGxapMJ3dAOBnHw6NLqBet+ZXBJEccX5y89MLlOQu+jDmJP8HieR
# qDLlwfhVZnw4RY8Rus43NZBwhNlVuOUXqocHKmJhKNlekda3qsJj93eQBQOxKJcc
# w5O08q9jukpH2+yzFAj+7ll33DuNUgWptogc4Awtbx3glzcEvJQwggaCMIIEaqAD
# AgECAhAo8HfBHDa9/l90MkdwJy4DMA0GCSqGSIb3DQEBDAUAMFYxCzAJBgNVBAYT
# AlBMMSEwHwYDVQQKExhBc3NlY28gRGF0YSBTeXN0ZW1zIFMuQS4xJDAiBgNVBAMT
# G0NlcnR1bSBUaW1lc3RhbXBpbmcgMjAyMSBDQTAeFw0yNjAzMTEwNzM0NTRaFw0z
# NjAyMjcwNzM0NTRaMFAxCzAJBgNVBAYTAlBMMSEwHwYDVQQKDBhBc3NlY28gRGF0
# YSBTeXN0ZW1zIFMuQS4xHjAcBgNVBAMMFUNlcnR1bSBUaW1lc3RhbXAgMjAyNjCC
# AiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALi9zfBw+xI12hi2F9uNQBNO
# STkTIi13fuh3DiWybI5392bBfJw6tO7zDs7UqP/rejVf4xFTU3XNOPsuOFkP5SPF
# snWwn3VJL+NhNhsuujvYsQtaQnvNo1wk46reTgws1L8OFEdKaxgyqT4zOQx81mLw
# NZSqXNU9Rrb9oAJWJF4Mydz+C1ebu0D0+vgM3tqAQ8TU203fqgKCPqIdHloaFqH9
# eyR+8tnpmy2hnnN59ZOtmsuHeRwf5iTOhlhf0d/qqquLOSIGPgGVL1Hj5XBUox2O
# E7tQRr+l/xxNbL6OuwBO4Aa2EbuxYg37cpXv5i2/lTL4NASB/duf9W4fjA3Ro+GP
# +bC54n2OYiM3Cq8z92ejx8F5HWO2e4/75eC95B9tMw/eq7109GDyW2+2ut5Bn0o8
# MXJTERkyystWbqcnUkomA97qfiDCwvpWuOMdfTOksXe9S+xDxOJaCbiyTivgPulx
# cT+tfQTRXBr5UwtPvc+INnafOEilzVHA11tv+pHcylryxBc08cUihf/ttqkZS0bm
# x3VxQay8uohaTp0//ECA9vYGW51SKdJMJhXvKyIdBLy8WYNchTt8yRITv9Adxg9J
# qGujh+TnP1bW3aQjFtlEl7SzgNeI8OVA/CJAWWUuRT3Au9ToOpHy/flrrknhN4he
# Ew7/6ILMv4HSsj0YZ4otAgMBAAGjggFQMIIBTDB1BggrBgEFBQcBAQRpMGcwOwYI
# KwYBBQUHMAKGL2h0dHA6Ly9zdWJjYS5yZXBvc2l0b3J5LmNlcnR1bS5wbC9jdHNj
# YTIwMjEuY2VyMCgGCCsGAQUFBzABhhxodHRwOi8vc3ViY2Eub2NzcC1jZXJ0dW0u
# Y29tMB8GA1UdIwQYMBaAFL5UAi+/QGxzQ86sCSVOnkNEGu7gMAwGA1UdEwEB/wQC
# MAAwOQYDVR0fBDIwMDAuoCygKoYoaHR0cDovL3N1YmNhLmNybC5jZXJ0dW0ucGwv
# Y3RzY2EyMDIxLmNybDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDCDAOBgNVHQ8BAf8E
# BAMCB4AwIgYDVR0gBBswGTAIBgZngQwBBAIwDQYLKoRoAYb2dwIFAQswHQYDVR0O
# BBYEFCM5aiirmhKnpKfyH/lFmr+N/oIDMA0GCSqGSIb3DQEBDAUAA4ICAQBm/pOb
# rAveTqYFPCSthxNVwBHTW4TfuBQY2KGCdMlH6ALjVPYf2XARNGMTXScR0JQ2/c2L
# xLpoHHhRwxFf3d933DhFS165rYj6iPSQKm41rc9AMpTCjMdmai1aK9o870pOzTzL
# RhWFVIxm+5Qf+t4V5z14tPoUsAWdfRKD1Zzvzfe5AHlE9rxOZcGROiULxs3Edf7Z
# J8RZI0jzD9juj2oEX2XS7BVKuNCK1W0h1mjapv028yqo8UtZh5/1Ib48Yyp88BDY
# Shh5PSGUUuS5tYXVfcduIPBe3PdNTYRYdT5VNxkwLgjMzIcr/6SH2fejFJDjnCxj
# vXLuarErJg/IdI+hXdGIBDVOGv1zrkF4ktoQvFZIp5DIAYh0GwyUhc87qqn5UaYT
# j2+iyiBDSNh7aGCrkuAnqg7Qh7frhX+QhMGeJ9SpLpnG76qN85hLyjvYR6gRYiLT
# j8G4fxjqV400/buPw0rBo9yJZyq/8cGdCOLPOYf9qpefTcrZKeO9pg+UJHx8UABb
# aTqizRxm8sUSEycccCCFhIit3AI/3wGuSOADeiA676NcQ7+dsP04YvzvdN7pTZNy
# g672WpBDJdg0eItHl4yomvNeIOWh1qs4QqOW6TI8Ocsi4uSDTLorxLUDnBTwKQzz
# pxd1YUctEgRh32R0J9FkFkeEruJU94RljFr1uDCCBrkwggShoAMCAQICEQCZo4AK
# JlU7ZavcboSms+o5MA0GCSqGSIb3DQEBDAUAMIGAMQswCQYDVQQGEwJQTDEiMCAG
# A1UEChMZVW5pemV0byBUZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVt
# IENlcnRpZmljYXRpb24gQXV0aG9yaXR5MSQwIgYDVQQDExtDZXJ0dW0gVHJ1c3Rl
# ZCBOZXR3b3JrIENBIDIwHhcNMjEwNTE5MDUzMjE4WhcNMzYwNTE4MDUzMjE4WjBW
# MQswCQYDVQQGEwJQTDEhMB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEu
# MSQwIgYDVQQDExtDZXJ0dW0gQ29kZSBTaWduaW5nIDIwMjEgQ0EwggIiMA0GCSqG
# SIb3DQEBAQUAA4ICDwAwggIKAoICAQCdI88EMCM7wUYs5zNzPmNdenW6vlxNur3r
# Lfi+5OZ+U3iZIB+AspO+CC/bj+taJUbMbFP1gQBJUzDUCPx7BNLgid1TyztVLn52
# NKgxxu8gpyTr6EjWyGzKU/gnIu+bHAse1LCitX3CaOE13rbuHbtrxF2tPU8f253Q
# gX6eO8yTbGps1Mg+yda3DcTsOYOhSYNCJiL+5wnjZ9weoGRtvFgMHtJg6i671OPX
# IciiHO4Lwo2p9xh/tnj+JmCQEn5QU0NxzrOiRna4kjFaA9ZcwSaG7WAxeC/xoZSx
# F1oK1UPZtKVt+yrsGKqWONoK6f5EmBOAVEK2y4ATDSkb34UD7JA32f+Rm0wsr5aj
# zftDhA5mBipVZDjHpwzv8bTKzCDUSUuUmPo1govD0RwFcTtMXcfJtm1i+P2UNXad
# PyYVKRxKQATHN3imsfBiNRdN5kiVVeqP55piqgxOkyt+HkwIA4gbmSc3hD8ke66t
# 9MjlcNg73rZZlrLHsAIV/nJ0mmgSjBI/TthoGJDydekOQ2tQD2Dup/+sKQptalDl
# ui59SerVSJg8gAeV7N/ia4mrGoiez+SqV3olVfxyLFt3o/OQOnBmjhKUANoKLYlK
# mUpKEFI0PfoT8Q1W/y6s9LTI6ekbi0igEbFUIBE8KDUGfIwnisEkBw5KcBZ3XwnH
# mfznwlKo8QIDAQABo4IBVTCCAVEwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQU
# 3XRdTADbe5+gdMqxbvc8wDLAcM0wHwYDVR0jBBgwFoAUtqFUOQLDoD+Oirz61Pgc
# ptE6Dv0wDgYDVR0PAQH/BAQDAgEGMBMGA1UdJQQMMAoGCCsGAQUFBwMDMDAGA1Ud
# HwQpMCcwJaAjoCGGH2h0dHA6Ly9jcmwuY2VydHVtLnBsL2N0bmNhMi5jcmwwbAYI
# KwYBBQUHAQEEYDBeMCgGCCsGAQUFBzABhhxodHRwOi8vc3ViY2Eub2NzcC1jZXJ0
# dW0uY29tMDIGCCsGAQUFBzAChiZodHRwOi8vcmVwb3NpdG9yeS5jZXJ0dW0ucGwv
# Y3RuY2EyLmNlcjA5BgNVHSAEMjAwMC4GBFUdIAAwJjAkBggrBgEFBQcCARYYaHR0
# cDovL3d3dy5jZXJ0dW0ucGwvQ1BTMA0GCSqGSIb3DQEBDAUAA4ICAQB1iFgP5Y9Q
# KJpTnxDsQ/z0O23JmoZifZdEOEmQvo/79PQg9nLF/GJe6ZiUBEyDBHMtFRK0mXj3
# Qv3gL0sYXe+PPMfwmreJHvgFGWQ7XwnfMh2YIpBrkvJnjwh8gIlNlUl4KENTK5DL
# qsYPEtRQCw7R6p4s2EtWyDDr/M58iY2UBEqfUU/ujR9NuPyKk0bEcEi62JGxauFY
# zZ/yld13fHaZskIoq2XazjaD0pQkcQiIueL0HKiohS6XgZuUtCKA7S6CHttZEsOb
# QJ1j2s0urIDdqF7xaXFVaTHKtAuMfwi0jXtF3JJphrJfc+FFILgCbX/uYBPBlbBI
# P4Ht4xxk2GmfzMn7oxPITpigQFJFWuzTMUUgdRHTxaTSKRJ/6Uh7ki/pFjf9sUAS
# WgxT69QF9Ki4JF5nBIujxZ2sOU9e1HSCJwOfK07t5nnzbs1LbHuAIGJsRJiQ6HX/
# DW1XFOlXY1rc9HufFhWU+7Uk+hFkJsfzqBz3pRO+5aI6u5abI4Qws4YaeJH7H7M8
# X/YNoaArZbV4Ql+jarKsE0+8XvC4DJB+IVcvC9Ydqahi09mjQse4fxfef0L7E3hh
# o2O3bLDM6v60rIRUCi2fJT2/IRU5ohgyTch4GuYWefSBsp5NPJh4QRTP9DC3gc5Q
# EKtbrTY0Ka87Web7/zScvLmvQBm8JDFpDjCCBrkwggShoAMCAQICEQDn/2nHOzXO
# S5Em2HR8aKWHMA0GCSqGSIb3DQEBDAUAMIGAMQswCQYDVQQGEwJQTDEiMCAGA1UE
# ChMZVW5pemV0byBUZWNobm9sb2dpZXMgUy5BLjEnMCUGA1UECxMeQ2VydHVtIENl
# cnRpZmljYXRpb24gQXV0aG9yaXR5MSQwIgYDVQQDExtDZXJ0dW0gVHJ1c3RlZCBO
# ZXR3b3JrIENBIDIwHhcNMjEwNTE5MDUzMjA3WhcNMzYwNTE4MDUzMjA3WjBWMQsw
# CQYDVQQGEwJQTDEhMB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMSQw
# IgYDVQQDExtDZXJ0dW0gVGltZXN0YW1waW5nIDIwMjEgQ0EwggIiMA0GCSqGSIb3
# DQEBAQUAA4ICDwAwggIKAoICAQDpEh8ENe25XXrFppVBvoplf0530W0lddNmjtv4
# YSh/f7eDQKFaIqc7tHj7ox+u8vIsJZlroakUeMS3i3T8aJRC+eQs4FF0GqvkM6+W
# ZO8kmzZfxmZaBYmMLs8FktgFYCzywmXeQ1fEExflee2OpbHVk665eXRHjH7MYZIz
# Nnjl2m8Hy8ulB9mR8wL/W0v0pjKNT6G0sfrx1kk+3OGosFUb7yWNnVkWKU4qSxLv
# 16kJ6oVJ4BSbZ4xMak6JLeB8szrK9vwGDpvGDnKCUMYL3NuviwH1x4gZG0JAXU3x
# 2pOAz91JWKJSAmRy/l0s0l5bEYKolg+DMqVhlOANd8Yh5mkQWaMEvBRE/kAGzIqg
# WhwzN2OsKIVtO8mf5sPWSrvyplSABAYa13rMYnzwfg08nljZHghquCJYCa/xHK9a
# cev9UD7Y+usr15d7mrszzxhF1JOr1Mpup2chNSBlyOObhlSO16rwrffVrg/SzaKf
# SndS5swRhr8bnDqNJY9TNyEYvBYpgF95K7p0g4LguR4A++Z1nFIHWVY5v0fNVZmg
# zxD9uVo/gta3onGOQj3JCxgYx0KrCXu4yc9QiVwTFLWbNdHFSjBCt5/8Q9pLuRhV
# ocdCunhcHudMS1CGQ/Rn0+7P+fzMgWdRKfEOh/hjLrnQ8BdJiYrZNxvIOhM2aa3z
# EDHNwwIDAQABo4IBVTCCAVEwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUvlQC
# L79AbHNDzqwJJU6eQ0Qa7uAwHwYDVR0jBBgwFoAUtqFUOQLDoD+Oirz61PgcptE6
# Dv0wDgYDVR0PAQH/BAQDAgEGMBMGA1UdJQQMMAoGCCsGAQUFBwMIMDAGA1UdHwQp
# MCcwJaAjoCGGH2h0dHA6Ly9jcmwuY2VydHVtLnBsL2N0bmNhMi5jcmwwbAYIKwYB
# BQUHAQEEYDBeMCgGCCsGAQUFBzABhhxodHRwOi8vc3ViY2Eub2NzcC1jZXJ0dW0u
# Y29tMDIGCCsGAQUFBzAChiZodHRwOi8vcmVwb3NpdG9yeS5jZXJ0dW0ucGwvY3Ru
# Y2EyLmNlcjA5BgNVHSAEMjAwMC4GBFUdIAAwJjAkBggrBgEFBQcCARYYaHR0cDov
# L3d3dy5jZXJ0dW0ucGwvQ1BTMA0GCSqGSIb3DQEBDAUAA4ICAQC4k1l3yUwV/ZQH
# CKCneqAs8EGTnwEUJLdDpokN/dMhKjK0rR5qX8nIIHzxpQR3TAw2IRw1Uxsr2Pli
# G3bCFqSdQTUbfaTq6V3vBzEebDru9QFjqlKnxCF2h1jhLNFFplbPJiW+JSnJTh1f
# KEqEdKdxgl9rVTvlxfEJ7exOn25MGbd/wGPwuSmMxRJVO0wnqgS7kmoJjNF9zqee
# hFSDDP8ZVkWg4EZ2tIS0M3uZmByRr+1Lkwjjt8AtW83mVnZTyTsOb+FNfwJY7DS4
# FmWhkRbgcHRetreoTirPOr/ozyDKhT8MTSTf6Lttg6s6T/u08mDWw6HK04ZRDfQ9
# sb77QV8mKgO44WGP31vXnVKoWVJpFBjPvjL8/Zck/5wXX2iqjOaLStFOR/IQki+E
# hn4zlcgVm22ZVCBPF+l8nAwUUShCtKuSU7GmZLKCmmxQMkSiWILTm8EtVD6AxnJh
# oq8EnhjEEyUoflkeRF2WhFiVQOmWTwZRr44IxWGkNJC6tTorW5rl2Zl+2e9JLPYf
# 3pStAPMDoPKIjVXd6NW2+fZrNUBeDo2eOa5Fn7Brs/HLQff5Xgris5MeUbdVgDrF
# 8uxO6cLPvZPo63j62SsNg55pTWk9fUIF9iPoRbb4QurjoY/woI1RAOKtYtTic6aA
# Jq3u83RIPpGXBSJKwx4KJAOZnCDCtTGCBqIwggaeAgEBMGowVjELMAkGA1UEBhMC
# UEwxITAfBgNVBAoTGEFzc2VjbyBEYXRhIFN5c3RlbXMgUy5BLjEkMCIGA1UEAxMb
# Q2VydHVtIENvZGUgU2lnbmluZyAyMDIxIENBAhBX5gfP8LabvnxCXoItEBa/MA0G
# CWCGSAFlAwQCAQUAoIGEMBgGCisGAQQBgjcCAQwxCjAIoAKAAKECgAAwGQYJKoZI
# hvcNAQkDMQwGCisGAQQBgjcCAQQwHAYKKwYBBAGCNwIBCzEOMAwGCisGAQQBgjcC
# ARUwLwYJKoZIhvcNAQkEMSIEIKzVct3hAl/IsGVDfN8wd7BZKLPeQjhMN3EaS1QU
# iXTrMA0GCSqGSIb3DQEBAQUABIIBgEzI6VPdwa5Rx/OhhiCqvdhxoTJM7OfOK5Wo
# lUkJr3FH9dKm4xxPs5WjaiLZ79gqIYerdVHGnJKhy/tDGbTb7awzeHcMWJeD2DF4
# YDZuLLjUMWUQbZrBQcfmW4VYgDnhMfmJgs10HUkZhR2Tn1Ljwp4mIcNzdFIQDUjL
# jG61pfGAgHeSOEWpVW0Q9AmADB2vFgi5a7ljvpY4N4WqAs4STbTPi+3sNiIMcP+S
# sTSICre71dlVeBhGHXZ+Gr1YJq/wzSO9MyDswWegi66bO8nRK9CQjEQQl86w2p7P
# ffGjtzomo2y/7UGjGZdHcOcp0knIUbuAYf56k4cC+55Rz5kZaCOtiQokUCv364/5
# ek0gGewUABMbgb5PsSXAi/e7+bfHu/zoDS4q2zgyt8UfpzHD2XfBb1EBtGihOdVs
# vP23qBXLntA8ogW4ym5pNNprvZ2klXZx69AYchpY4nbTbgNb9LXCbbixea+T8h1M
# 6UsVbdnnHTabKLaCNmT+2gTEVjGIiaGCBAIwggP+BgkqhkiG9w0BCQYxggPvMIID
# 6wIBATBqMFYxCzAJBgNVBAYTAlBMMSEwHwYDVQQKExhBc3NlY28gRGF0YSBTeXN0
# ZW1zIFMuQS4xJDAiBgNVBAMTG0NlcnR1bSBUaW1lc3RhbXBpbmcgMjAyMSBDQQIQ
# KPB3wRw2vf5fdDJHcCcuAzANBglghkgBZQMEAgIFAKCCAVYwGgYJKoZIhvcNAQkD
# MQ0GCyqGSIb3DQEJEAEEMBwGCSqGSIb3DQEJBTEPFw0yNjEwMjIxMTM4MTdaMDcG
# CyqGSIb3DQEJEAIvMSgwJjAkMCIEIIW+kOEK0kONfMkotq9IsJqyCBd87PiwEmxY
# 05EFJcQ8MD8GCSqGSIb3DQEJBDEyBDCE7cyVMoMgXsJ4ek5ZJErSzX5kXR968Jka
# AXdiydvg29YjMB1touAwrmyr+XEQcyMwgZ8GCyqGSIb3DQEJEAIMMYGPMIGMMIGJ
# MIGGBBRXFGhBDKha80JO+RZKUTYQ9NONmDBuMFqkWDBWMQswCQYDVQQGEwJQTDEh
# MB8GA1UEChMYQXNzZWNvIERhdGEgU3lzdGVtcyBTLkEuMSQwIgYDVQQDExtDZXJ0
# dW0gVGltZXN0YW1waW5nIDIwMjEgQ0ECECjwd8EcNr3+X3QyR3AnLgMwDQYJKoZI
# hvcNAQEBBQAEggIAFQDoQwQxqX1w8lU06QNy7C2Vrk2PZpP8TUN/RqrWGA30OiRK
# SBVkwdiRIcb86++kcuUcZDNkkLHcYPGypw1AH+s52XJNErGte3Qy2igh+nhctIx+
# cl6nY7bvd0K1/h8uqIUyq0z0iRrD8mBCbd88R/YJZX+xhICrrWgYnHv2PYEu3clS
# 32MKGubftwVTQUPl38g2ekSnJMiC74sYXCO8tDSJYS/FeCxzS8L/ko+G5KqlOIcm
# 4Q3GDp+J6Sp3J4IBzypLxaSpTkPllJo7v+4Xa5ip3UmozApO/W2fP1a1zxrie+Hs
# kcYFT7SFoEvx7l4A3fb5WTkedmpDVxDpj0x6Y7Gxa8fgUGPUw0WEl5VliXHDyuAC
# 0gl2pEJZW8W8X4e7R18z1ryEQ3la6e5tlmOpjSBH5hEVRX2PmulliwehguvJvyA9
# wMj2LgzBO2CvrdZ2VZ6MPe9Ko4kvDY7bsJ2blwlDxh/FpVKuEn+3QN3Hqgs6GKMx
# qCETG+qmB+yjYcZH+ksBhuQzvJeNBUK6EEHskRBSUjOh238UtH3adv716MnERqZB
# neAYIRKk75ltDxLZjsZTBxg3qV8Hs2l/HtHHSB40/4xV8Y/b/awbFq8K/w0ti1Dw
# Ec37yN8Q+TumJuAXxTO3ObU2JfRcnflKUMg+D5nsOXJ0NRTVP9ecjwkF4/0=
# SIG # End signature block