Private/Set-Win32ToolkitAppRelationships.ps1
|
function Set-Win32ToolkitAppRelationships { <# .SYNOPSIS Attaches mobileAppDependency relationships to a published Win32 app, WITHOUT destroying the app's existing relationships. .DESCRIPTION Intune has no "add one dependency" API. The only write is POST /beta/deviceAppManagement/mobileApps/{id}/updateRelationships -> 204 { "relationships": [ { "@odata.type": "#microsoft.graph.mobileAppDependency", "targetId": "<guid>", "dependencyType": "autoInstall" } ] } and it is a SET operation across BOTH dependencies AND supersedence. Posting only the new dependency therefore SILENTLY DELETES every supersedence rule an admin configured in the portal. So this is always read -> merge -> write. THREE traps this function exists to avoid: 1. SUPERSEDENCE WIPE — see above. Existing relationships are read back and re-posted alongside ours. 2. THE targetType='parent' TRAP — GET /relationships returns BOTH directions. Rows with targetType 'parent' are INBOUND (other apps that depend on / supersede THIS one). Re-posting them yields "A circular dependency was created while adding app relationships". We keep ONLY targetType 'child'. (This is the open bug in the community IntuneWin32App module, issue #171.) 3. THE SINGLE-ELEMENT JSON COLLAPSE — ConvertTo-Json renders a 1-element array as a bare object, which Graph rejects with "Expected array for value of property: Collection(...)". The one dependency case IS the main use case, so the array is forced and asserted in tests. Only @odata.type / targetId / dependencyType are sent. Every other property on the relationship (targetType, sourceId, targetDisplayName, id, ...) is read-only; echoing them back is what produces the malformed / circular errors. .PARAMETER AppId The PARENT app — the one that NEEDS the dependencies. .PARAMETER Dependency Objects with TargetId + DependencyType ('autoInstall' | 'detect'). Pass an empty array to clear the app's dependencies (existing supersedence is still preserved). .PARAMETER BaseUri Graph base. Defaults to the /beta deviceAppManagement root the rest of the module uses. .OUTPUTS [int] the number of dependency relationships now attached. #> [CmdletBinding(SupportsShouldProcess)] [OutputType([int])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string]$AppId, [object[]]$Dependency = @(), [string]$BaseUri = 'https://graph.microsoft.com/beta/deviceAppManagement' ) foreach ($d in @($Dependency)) { if ($d.TargetId -eq $AppId) { throw "An app cannot depend on itself (app id $AppId)." } } # ── READ ─────────────────────────────────────────────────────────────────────────────────────── $existing = @() try { $resp = Invoke-MgGraphRequest -Method GET -Uri "$BaseUri/mobileApps/$AppId/relationships" -OutputType PSObject if ($resp -and $resp.value) { $existing = @($resp.value) } } catch { throw "Could not read the existing relationships of app $AppId (they must be preserved, or supersedence would be destroyed): $($_.Exception.Message)" } # ── MERGE ────────────────────────────────────────────────────────────────────────────────────── # AUTHORITY MODEL: the project's declared dependencies are the TRUTH for DEPENDENCIES — existing # dependency rows are replaced, so removing a dependency from the project actually removes it from # Intune on the next publish (otherwise a stale dependency could never be deleted). SUPERSEDENCE is # NOT managed by the toolkit, so it is read back and re-posted verbatim; dropping it here is what # would silently destroy an admin's supersedence configuration. $rels = [System.Collections.Generic.List[object]]::new() foreach ($e in $existing) { # Keep ONLY outbound rows. Inbound ('parent') rows re-posted => "circular dependency" error. if ($e.targetType -ne 'child') { continue } if ([string]$e.'@odata.type' -notmatch 'mobileAppSupersedence') { continue } $rels.Add([ordered]@{ '@odata.type' = '#microsoft.graph.mobileAppSupersedence' 'targetId' = $e.targetId 'supersedenceType' = $e.supersedenceType }) } # Dedupe our own declarations by targetId (a duplicate relationship is rejected by Graph). $seen = @{} foreach ($d in @($Dependency)) { if ($seen.ContainsKey($d.TargetId)) { continue } $seen[$d.TargetId] = $true $rels.Add([ordered]@{ '@odata.type' = '#microsoft.graph.mobileAppDependency' 'targetId' = $d.TargetId 'dependencyType' = if ($d.DependencyType) { $d.DependencyType } else { 'autoInstall' } }) } # ── WRITE ────────────────────────────────────────────────────────────────────────────────────── # [object[]] + -Depth: force a JSON ARRAY even for a single element (Graph rejects a bare object). $body = ConvertTo-Json -InputObject @{ relationships = [object[]]@($rels) } -Depth 6 if ($body -notmatch '"relationships"\s*:\s*\[') { throw "Refusing to POST: the relationships payload did not serialize as a JSON array (Graph would reject it). Body: $body" } if (-not $PSCmdlet.ShouldProcess($AppId, "Set $($rels.Count) relationship(s) — $(@($Dependency).Count) dependency(ies)")) { return @($Dependency).Count } try { Invoke-MgGraphRequest -Method POST -Uri "$BaseUri/mobileApps/$AppId/updateRelationships" ` -Body $body -ContentType 'application/json' | Out-Null } catch { # Never advise "re-publish": Publish always CREATES A NEW app (there is no update path), so that # would duplicate the app and leave the assigned one without its dependency. # Sync-Win32ToolkitAppDependency updates the app that is already in the tenant. $m = $_.Exception.Message $fix = "The app IS published, just without its dependency relationship. Fix the cause, then run Sync-Win32ToolkitAppDependency -ProjectPath <project> to attach it to THIS app (do NOT re-publish — that creates a second app)." if ($m -match '403|Forbidden') { throw "Intune refused the relationship write (403). A correct Graph scope is not enough: your admin account also needs the Intune RBAC 'Relate' permission (Mobile apps category). $fix ($m)" } throw "Failed to attach dependencies to app $AppId. $fix ($m)" } return @($Dependency).Count } |