|
| 1 | +function New-TeamsRequestV2 { |
| 2 | + <# |
| 3 | + .SYNOPSIS |
| 4 | + Talks to the Teams admin ConfigAPI (api.interfaces.records.teams.microsoft.com) |
| 5 | + directly, instead of the MicrosoftTeams PowerShell module. |
| 6 | +
|
| 7 | + .DESCRIPTION |
| 8 | + The MicrosoftTeams module routes policy-definition writes through the legacy |
| 9 | + /Skype.Policy/tenants/policies surface, which returns 40301 Forbidden under CSP/GDAP |
| 10 | + (for both delegate-with-Teams-Admin and app-only-with-Global-Admin tokens). The Teams |
| 11 | + admin center (ACMS) instead uses /Skype.Policy/configurations/{Type}/configuration/{Identity}, |
| 12 | + which authorizes fine with the roles CIPP already has. This helper speaks that surface. |
| 13 | +
|
| 14 | + All HTTP goes through Invoke-CIPPRestMethod (pooled CIPP.CIPPRestClient) so gzip |
| 15 | + responses are decompressed and JSON is deserialized consistently across platforms |
| 16 | + (raw Invoke-RestMethod does NOT decompress gzip in the Linux worker, which yields |
| 17 | + garbage/null objects and unreadable error bodies). |
| 18 | +
|
| 19 | + Operations: |
| 20 | + Get -> GET configurations/{Type}/configuration/{Identity} (single object) |
| 21 | + Get -ListAll -> GET configurations/{Type} (all instances, array) |
| 22 | + Set -> PUT configurations/{Type}/configuration/{Identity} (MERGE; only changed props) |
| 23 | + New -> PUT configurations/{Type}/configuration/{Identity} (create named policy) [best-effort] |
| 24 | + Remove -> DELETE configurations/{Type}/configuration/{Identity} [best-effort] |
| 25 | +
|
| 26 | + .PARAMETER TenantFilter |
| 27 | + Target tenant (GUID or default domain). |
| 28 | +
|
| 29 | + .PARAMETER Type |
| 30 | + ConfigAPI type (e.g. 'TeamsMeetingPolicy'), a cmdlet noun, or a full cmdlet name |
| 31 | + (e.g. 'Set-CsTenantFederationConfiguration'); the verb is stripped and known |
| 32 | + noun->type aliases are applied automatically. |
| 33 | +
|
| 34 | + .PARAMETER Action |
| 35 | + Get (default) | Set | New | Remove. |
| 36 | +
|
| 37 | + .PARAMETER Identity |
| 38 | + Policy instance identity. Default 'Global'. (e.g. 'Tag:Default' or a custom name.) |
| 39 | +
|
| 40 | + .PARAMETER Parameters |
| 41 | + Hashtable of properties to write (Set/New). Only these are sent (merge). |
| 42 | +
|
| 43 | + .PARAMETER ListAll |
| 44 | + Get: return every instance of the type (array) instead of one identity. |
| 45 | +
|
| 46 | + .PARAMETER AsApp |
| 47 | + Use an app-only (client_credentials) token instead of the delegate token. |
| 48 | +
|
| 49 | + .PARAMETER NoRead |
| 50 | + Set: skip the read-for-Key step and PUT only the bare changed props (ACMS-style). |
| 51 | + Faster; safe for flat/Host-authority types and federation. Default is read-modify-write |
| 52 | + (adds the Key envelope when the type carries one) for maximum compatibility. |
| 53 | +
|
| 54 | + .PARAMETER UseServiceDiscovery |
| 55 | + Resolve the per-tenant ConfigApi host + X-MS-Forest via Teams.Tenant/tenants and use |
| 56 | + them (and, for federation/ACS types, the OcsPowershellWebservice target headers). |
| 57 | +
|
| 58 | + .EXAMPLE |
| 59 | + New-TeamsRequestV2 -TenantFilter $t -Type TeamsMeetingPolicy -Action Set -Parameters @{ AllowAnonymousUsersToJoinMeeting = $false } |
| 60 | +
|
| 61 | + .FUNCTIONALITY |
| 62 | + Internal |
| 63 | + #> |
| 64 | + [CmdletBinding()] |
| 65 | + param( |
| 66 | + [Parameter(Mandatory)] $TenantFilter, |
| 67 | + [Parameter(Mandatory)] [string] $Type, |
| 68 | + [ValidateSet('Get', 'Set', 'New', 'Remove')] [string] $Action = 'Get', |
| 69 | + [string] $Identity = 'Global', |
| 70 | + [hashtable] $Parameters = @{}, |
| 71 | + [switch] $ListAll, |
| 72 | + [switch] $AsApp, |
| 73 | + [switch] $NoRead, |
| 74 | + [switch] $UseServiceDiscovery |
| 75 | + ) |
| 76 | + |
| 77 | + # ---- cmdlet-noun -> ConfigAPI type aliases (noun != type) ---- |
| 78 | + $TypeAliases = @{ |
| 79 | + 'TenantFederationConfiguration' = 'TenantFederationSettings' |
| 80 | + } |
| 81 | + # types backed by the legacy OcsPowershellWebservice (need target-uri headers via discovery) |
| 82 | + $FederationTypes = @('TenantFederationSettings', 'TeamsAcsFederationConfiguration') |
| 83 | + |
| 84 | + # normalize Type: strip Get-/Set-/New-/Remove-Cs prefix, then alias |
| 85 | + $ConfigType = $Type -replace '^(Get|Set|New|Remove|Grant|Revoke)-Cs', '' |
| 86 | + if ($TypeAliases.ContainsKey($ConfigType)) { $ConfigType = $TypeAliases[$ConfigType] } |
| 87 | + |
| 88 | + # ---- token ---- |
| 89 | + $TokenSplat = @{ tenantid = $TenantFilter; scope = '48ac35b8-9aa8-4d74-927d-1f4a14a0b239/.default' } |
| 90 | + if ($AsApp) { $TokenSplat['AsApp'] = $true } |
| 91 | + $TeamsToken = (Get-GraphToken @TokenSplat).Authorization -replace 'Bearer ' |
| 92 | + |
| 93 | + # ---- endpoint + forest (service discovery optional) ---- |
| 94 | + $ApiHost = 'api.interfaces.records.teams.microsoft.com' |
| 95 | + $Forest = $null |
| 96 | + $AdminServiceEndpoint = $null |
| 97 | + $AdminDomain = $null |
| 98 | + if ($UseServiceDiscovery) { |
| 99 | + try { |
| 100 | + $disc = Invoke-CIPPRestMethod -Uri "https://$ApiHost/Teams.Tenant/tenants" -Method GET ` |
| 101 | + -Headers @{ Authorization = "Bearer $TeamsToken"; Accept = 'application/json' } |
| 102 | + if ($disc.serviceDiscovery.Endpoints.ConfigApiEndpoint) { $ApiHost = $disc.serviceDiscovery.Endpoints.ConfigApiEndpoint } |
| 103 | + $Forest = $disc.serviceDiscovery.Headers.'X-MS-Forest' |
| 104 | + $AdminServiceEndpoint = $disc.serviceDiscovery.Endpoints.AdminServiceEndpoint |
| 105 | + $AdminDomain = ($disc.verifiedDomains | Where-Object { $_.name -like '*.onmicrosoft.com' -and $_.name -notlike '*.*.onmicrosoft.com' } | Select-Object -First 1).name |
| 106 | + } catch { |
| 107 | + Write-Verbose "Service discovery failed, using defaults: $($_.Exception.Message)" |
| 108 | + } |
| 109 | + } |
| 110 | + $Base = "https://$ApiHost/Skype.Policy/configurations/$ConfigType" |
| 111 | + |
| 112 | + # ---- headers ---- |
| 113 | + $Headers = @{ |
| 114 | + Authorization = "Bearer $TeamsToken" |
| 115 | + Accept = 'application/json' |
| 116 | + 'x-authz-scope' = 'tenant' |
| 117 | + 'x-ms-correlation-id' = (New-Guid).Guid |
| 118 | + } |
| 119 | + if ($Forest) { $Headers['x-ms-forest'] = $Forest } |
| 120 | + if ($ConfigType -in $FederationTypes -and $AdminServiceEndpoint) { |
| 121 | + $Headers['x-ms-target-uri'] = "https://$AdminServiceEndpoint/OcsPowershellWebservice" |
| 122 | + $Headers['x-ms-tenant-id'] = $TenantFilter |
| 123 | + } |
| 124 | + $Query = if ($ConfigType -in $FederationTypes -and $AdminDomain) { "?adminDomain=$AdminDomain" } else { '' } |
| 125 | + |
| 126 | + switch ($Action) { |
| 127 | + |
| 128 | + 'Get' { |
| 129 | + $Uri = if ($ListAll) { "$Base$Query" } else { "$Base/configuration/$Identity$Query" } |
| 130 | + return Invoke-CIPPRestMethod -Uri $Uri -Method GET -Headers $Headers |
| 131 | + } |
| 132 | + |
| 133 | + 'Set' { |
| 134 | + $Uri = "$Base/configuration/$Identity$Query" |
| 135 | + |
| 136 | + # build body of only the changed props (skip control keys) |
| 137 | + $Body = [ordered]@{} |
| 138 | + foreach ($k in $Parameters.Keys) { |
| 139 | + if ($k -in @('Identity', 'ErrorAction', 'Confirm', 'WhatIf', 'Verbose', 'Debug')) { continue } |
| 140 | + $Body[$k] = $Parameters[$k] |
| 141 | + } |
| 142 | + |
| 143 | + if (-not $NoRead) { |
| 144 | + # read-modify-write: include the Key envelope when the type carries one (max compat) |
| 145 | + try { |
| 146 | + $Current = Invoke-CIPPRestMethod -Uri $Uri -Method GET -Headers $Headers |
| 147 | + if ($Current -and ($Current.PSObject.Properties.Name -contains 'Key')) { |
| 148 | + $Merged = [ordered]@{ Identity = $Identity; Key = $Current.Key } |
| 149 | + foreach ($k in $Body.Keys) { $Merged[$k] = $Body[$k] } |
| 150 | + $Body = $Merged |
| 151 | + } |
| 152 | + } catch { |
| 153 | + Write-Verbose "Pre-read failed ($($_.Exception.Message)); sending bare props." |
| 154 | + } |
| 155 | + } |
| 156 | + |
| 157 | + $Json = $Body | ConvertTo-Json -Depth 25 -Compress |
| 158 | + $StatusCode = $null |
| 159 | + $RespBody = Invoke-CIPPRestMethod -Uri $Uri -Method PUT -Body $Json -ContentType 'application/json' ` |
| 160 | + -Headers $Headers -SkipHttpErrorCheck -StatusCodeVariable StatusCode |
| 161 | + if ([int]$StatusCode -ge 400) { |
| 162 | + $Detail = if ($RespBody -is [string]) { $RespBody } elseif ($null -ne $RespBody) { $RespBody | ConvertTo-Json -Compress -Depth 10 } else { '' } |
| 163 | + throw "Teams ConfigApi Set $ConfigType/$Identity failed: $StatusCode $Detail" |
| 164 | + } |
| 165 | + return [pscustomobject]@{ Type = $ConfigType; Identity = $Identity; StatusCode = [int]$StatusCode } |
| 166 | + } |
| 167 | + |
| 168 | + 'New' { |
| 169 | + # best-effort: create a named policy by PUTting the props at a new identity |
| 170 | + $Uri = "$Base/configuration/$Identity$Query" |
| 171 | + $Body = [ordered]@{ Identity = $Identity } |
| 172 | + foreach ($k in $Parameters.Keys) { |
| 173 | + if ($k -in @('Identity', 'ErrorAction', 'Confirm', 'WhatIf', 'Verbose', 'Debug')) { continue } |
| 174 | + $Body[$k] = $Parameters[$k] |
| 175 | + } |
| 176 | + $Json = $Body | ConvertTo-Json -Depth 25 -Compress |
| 177 | + $StatusCode = $null |
| 178 | + $RespBody = Invoke-CIPPRestMethod -Uri $Uri -Method PUT -Body $Json -ContentType 'application/json' ` |
| 179 | + -Headers $Headers -SkipHttpErrorCheck -StatusCodeVariable StatusCode |
| 180 | + if ([int]$StatusCode -ge 400) { |
| 181 | + $Detail = if ($RespBody -is [string]) { $RespBody } elseif ($null -ne $RespBody) { $RespBody | ConvertTo-Json -Compress -Depth 10 } else { '' } |
| 182 | + throw "Teams ConfigApi New $ConfigType/$Identity failed: $StatusCode $Detail" |
| 183 | + } |
| 184 | + return [pscustomobject]@{ Type = $ConfigType; Identity = $Identity; StatusCode = [int]$StatusCode } |
| 185 | + } |
| 186 | + |
| 187 | + 'Remove' { |
| 188 | + $Uri = "$Base/configuration/$Identity$Query" |
| 189 | + $StatusCode = $null |
| 190 | + $RespBody = Invoke-CIPPRestMethod -Uri $Uri -Method DELETE -Headers $Headers -SkipHttpErrorCheck -StatusCodeVariable StatusCode |
| 191 | + if ([int]$StatusCode -ge 400) { |
| 192 | + $Detail = if ($RespBody -is [string]) { $RespBody } elseif ($null -ne $RespBody) { $RespBody | ConvertTo-Json -Compress -Depth 10 } else { '' } |
| 193 | + throw "Teams ConfigApi Remove $ConfigType/$Identity failed: $StatusCode $Detail" |
| 194 | + } |
| 195 | + return [pscustomobject]@{ Type = $ConfigType; Identity = $Identity; StatusCode = [int]$StatusCode } |
| 196 | + } |
| 197 | + } |
| 198 | +} |
0 commit comments