Public/Set-WindowsDeviceLinkTenant.ps1
|
function Set-WindowsDeviceLinkTenant { <# .SYNOPSIS Ensures that the local device is pre-associated using Direct or Backend mode. .DESCRIPTION Direct mode uses one Microsoft Graph authentication context and can perform New or no-op in that tenant. TenantId is optional for interactive and device-code sign-in; when omitted, the authenticated tenant context is authoritative. Backend mode uses the Function tenant catalog and authoritative multitenant lookup to choose New, None, or Move. The backend never chooses the target tenant. A Move is a deliberate physical-device transfer: after confirmation, this cmdlet clears the local DeviceLink UEFI identity, creates a new TPM-backed identity, and asks the backend to delete the proven source record and pre-associate the new identity with the target. Cloud state is then read back across all configured tenants. No mutation is blindly retried. Run this operation from Windows PE or from the intended OOBE servicing context. Clearing DeviceLink firmware doesn't unenroll Windows or remove Entra/Intune device records belonging to the previous deployment. #> [CmdletBinding(DefaultParameterSetName='Direct',SupportsShouldProcess,ConfirmImpact='High')] param( [Parameter(Mandatory,ParameterSetName='Direct')] [ValidateSet('DeviceCode','Interactive','ClientSecret','AccessToken','Certificate','CertificateThumbprint','CertificateSubjectName','EnvironmentVariable','ManagedIdentity')] [string]$Method, [Parameter(ParameterSetName='Direct')][ValidateNotNullOrEmpty()][string]$TenantId, [Parameter(ParameterSetName='Direct')][ValidateNotNullOrEmpty()][string]$ClientId, [Parameter(ParameterSetName='Direct')][securestring]$AccessToken, [Parameter(ParameterSetName='Direct')][System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, [Parameter(ParameterSetName='Direct')][ValidateNotNullOrEmpty()][string]$CertificateThumbprint, [Parameter(ParameterSetName='Direct')][ValidateNotNullOrEmpty()][string]$CertificateSubjectName, [Parameter(ParameterSetName='Direct')][bool]$SendCertificateChain = $false, [Parameter(ParameterSetName='Direct')][securestring]$ClientSecret, [Parameter(ParameterSetName='Direct')][ValidateNotNullOrEmpty()][string]$Environment = 'Global', [Parameter(ParameterSetName='Direct')][ValidateRange(1,600)][double]$ClientTimeout = 100, [Parameter(Mandatory,ParameterSetName='BackendById')] [Parameter(Mandatory,ParameterSetName='BackendByName')] [ValidateNotNull()][uri]$BackendUri, [Parameter(Mandatory,ParameterSetName='BackendById')] [Parameter(Mandatory,ParameterSetName='BackendByName')] [ValidateNotNull()][securestring]$BackendApiKey, [Parameter(Mandatory,ParameterSetName='BackendById')][guid]$TargetTenantId, [Parameter(Mandatory,ParameterSetName='BackendByName')][ValidateNotNullOrEmpty()][string]$TargetTenantName, [Parameter(ParameterSetName='BackendById')] [Parameter(ParameterSetName='BackendByName')] [switch]$RepairExistingAssociation, [ValidateNotNullOrEmpty()][string]$WindowsManagementServicePath, [ValidateRange(5,600)][int]$TimeoutSeconds = 120 ) if ($PSCmdlet.ParameterSetName -eq 'Direct') { $parameters = @{ Method=$Method TimeoutSeconds=$TimeoutSeconds Environment=$Environment ClientTimeout=$ClientTimeout } foreach ($name in @('TenantId','ClientId','AccessToken','Certificate','CertificateThumbprint','CertificateSubjectName','SendCertificateChain','ClientSecret','WindowsManagementServicePath')) { if ($PSBoundParameters.ContainsKey($name)) { $parameters[$name]=$PSBoundParameters[$name] } } $initialization = Initialize-WindowsDeviceLink @parameters $decision = switch ([string]$initialization.Action) { 'Register' { 'New' } 'None' { 'None' } default { 'Blocked' } } $effectiveTenant = if ($initialization.AfterStatus.TenantId) { [string]$initialization.AfterStatus.TenantId } elseif ($TenantId) { [string]$TenantId } else { $null } $success = $decision -ne 'Blocked' return [pscustomobject]@{ PSTypeName='Windows.DeviceLink.TenantAssignmentResult' Success=$success Changed=[bool]$initialization.Changed OperationMode='Direct' Decision=$decision ReasonCode=if ($decision -eq 'New') { 'WDL-DIRECT-NEW' } elseif ($decision -eq 'None') { 'WDL-DIRECT-NO-CHANGE' } else { 'WDL-DIRECT-BLOCKED' } RetrySafe=if ($success) { $true } else { $false } RecommendedAction=if ($success) { 'None' } else { [string]$initialization.Message } SerialNumber=[string]$initialization.SerialNumber SourceTenantId=$effectiveTenant TargetTenantId=$effectiveTenant TargetTenantName=$null PreviousLinkId=[string]$initialization.LinkId NewLinkId=[string]$initialization.LinkId AssociationId=[string]$initialization.AssociationId AssociationState=[string]$initialization.AssociationState RequestId=$null Message=[string]$initialization.Message Details=$initialization } } $credential = New-Object System.Management.Automation.PSCredential('api-key',$BackendApiKey) $plainKey = $null try { $plainKey = $credential.GetNetworkCredential().Password $catalog = Invoke-WindowsDeviceLinkBackendTenantCatalog -BackendUri $BackendUri -BackendApiKey $plainKey -TimeoutSeconds $TimeoutSeconds $catalogTenants = @($catalog.tenants) if ($PSCmdlet.ParameterSetName -eq 'BackendByName') { $targetMatches = @($catalogTenants | Where-Object { [string]$_.name -ieq $TargetTenantName.Trim() }) if ($targetMatches.Count -ne 1) { throw "Target tenant name '$TargetTenantName' resolved to $($targetMatches.Count) catalog entries; exactly one is required." } $targetId = ([guid][string]$targetMatches[0].tenantId).ToString().ToLowerInvariant() $targetName = [string]$targetMatches[0].name } else { $targetId = $TargetTenantId.ToString().ToLowerInvariant() $targetMatches = @($catalogTenants | Where-Object { [string]$_.tenantId -ieq $targetId }) if ($targetMatches.Count -ne 1) { throw "Target tenant '$targetId' is not uniquely present in the Function backend catalog." } $targetName = [string]$targetMatches[0].name } $runtime = @{ TimeoutSeconds=$TimeoutSeconds } if ($PSBoundParameters.ContainsKey('WindowsManagementServicePath')) { $runtime.WindowsManagementServicePath=$WindowsManagementServicePath } $currentDeviceLink = Get-WindowsDeviceLink @runtime $localBefore = Get-WindowsDeviceLinkLocalAssociation if ($localBefore.ConflictDetected) { throw 'Local tenant evidence conflicts. No local or cloud state was changed.' } if ([string]$localBefore.FirmwareState -notin @('2/4','4/4')) { throw "Local firmware state '$($localBefore.FirmwareState)' isn't safe for tenant assignment. No state was changed." } $lookup = Invoke-WindowsDeviceLinkBackendLookup -BackendUri $BackendUri -BackendApiKey $plainKey -SerialNumber $currentDeviceLink.SerialNumber -TimeoutSeconds $TimeoutSeconds $tenantRows = @($lookup.tenants) $matches = @($lookup.matches) if ($lookup.success -ne $true -or [int]$lookup.failedTenantCount -ne 0 -or [int]$lookup.successfulTenantCount -ne $catalogTenants.Count -or [int]$lookup.searchedTenantCount -ne $catalogTenants.Count -or $tenantRows.Count -ne $catalogTenants.Count -or @($lookup.tenantErrors).Count -ne 0 -or @($tenantRows | Where-Object { $_.success -ne $true }).Count -ne 0) { throw '[WDL-BACKEND-LOOKUP-INCOMPLETE] The Function lookup did not prove a complete read across the configured tenant catalog. No state was changed.' } $catalogIds = @($catalogTenants | ForEach-Object { ([guid][string]$_.tenantId).ToString().ToLowerInvariant() } | Sort-Object) $lookupIds = @($tenantRows | ForEach-Object { ([guid][string]$_.tenantId).ToString().ToLowerInvariant() } | Sort-Object) if (@(Compare-Object $catalogIds $lookupIds).Count -ne 0) { throw '[WDL-BACKEND-CATALOG-MISMATCH] The Function lookup tenant set differs from the authenticated tenant catalog. No state was changed.' } if ($matches.Count -gt 1 -or [int]$lookup.matchCount -ne $matches.Count) { throw '[WDL-BACKEND-ASSOCIATION-AMBIGUOUS] The Function lookup returned ambiguous or inconsistent association state. No state was changed.' } $source = if ($matches.Count -eq 1) { $matches[0] } else { $null } $sourceId = if ($source) { ([guid][string]$source.tenantId).ToString().ToLowerInvariant() } else { $null } if ($source -and ([string]$source.serialNumber -ine [string]$currentDeviceLink.SerialNumber -or [string]$source.associationState -notin @('preassociated','associated'))) { throw 'The Function lookup returned a source record with an unexpected serial number or association state. No state was changed.' } if ($sourceId -and $localBefore.TenantId -and [string]$localBefore.TenantId -ine $sourceId) { throw '[WDL-LOCAL-CLOUD-CONFLICT] Local tenant evidence differs from the authoritative cloud source. No state was changed.' } $assignmentDecision = Resolve-WindowsDeviceLinkTenantAssignmentDecision -OperationMode Backend ` -SourceTenantId $sourceId -TargetTenantId $targetId -FirmwareState ([string]$localBefore.FirmwareState) $decision = [string]$assignmentDecision.Decision if ($decision -eq 'None' -and $RepairExistingAssociation) { if ([string]$localBefore.FirmwareState -ne '2/4' -or -not $source -or [string]$source.associationState -ine 'associated') { throw '[WDL-BACKEND-REPAIR-NOT-APPLICABLE] Same-tenant repair requires local firmware state 2/4 and an associated cloud record in the selected tenant. No state was changed.' } $decision = 'Repair' } if ($decision -eq 'None') { return [pscustomobject]@{ PSTypeName='Windows.DeviceLink.TenantAssignmentResult'; Success=$true; Decision='None'; Changed=$false; OperationMode='Backend' ReasonCode='WDL-BACKEND-NO-CHANGE'; RetrySafe=$true; RecommendedAction='None' SerialNumber=[string]$currentDeviceLink.SerialNumber; SourceTenantId=$sourceId; TargetTenantId=$targetId TargetTenantName=$targetName; PreviousLinkId=[string]$currentDeviceLink.LinkId; NewLinkId=[string]$currentDeviceLink.LinkId AssociationId=[string]$source.associationId; AssociationState=[string]$source.associationState Message="The device is already present in target tenant '$targetName'. No change was made." } } $renewIdentity = [bool]$assignmentDecision.IdentityRenewalRequired $action = if ($decision -eq 'Repair') { "Replace the stale cloud association in $targetName ($targetId) with the current local DeviceLink identity" } elseif ($decision -eq 'Move') { "Renew the local DeviceLink identity, remove the association from tenant $sourceId, and pre-associate it with $targetName ($targetId)" } elseif ($renewIdentity) { "Create a fresh local DeviceLink identity and pre-associate it with $targetName ($targetId)" } else { "Pre-associate the current DeviceLink identity with $targetName ($targetId)" } if (-not $PSCmdlet.ShouldProcess([string]$currentDeviceLink.SerialNumber,$action)) { return [pscustomobject]@{ PSTypeName='Windows.DeviceLink.TenantAssignmentResult'; Success=$true; Decision=$decision; Changed=$false; OperationMode='Backend' ReasonCode=if($decision -eq 'Move'){'WDL-BACKEND-MOVE'}elseif($decision -eq 'Repair'){'WDL-BACKEND-REPAIR'}else{'WDL-BACKEND-NEW'}; RetrySafe=$true; RecommendedAction='Review WhatIf output and run without -WhatIf to apply.' SerialNumber=[string]$currentDeviceLink.SerialNumber; SourceTenantId=$sourceId; TargetTenantId=$targetId TargetTenantName=$targetName; PreviousLinkId=[string]$currentDeviceLink.LinkId; NewLinkId=$null AssociationId=$null; AssociationState=$null; Message="Would perform $decision for target tenant '$targetName'." } } $previousLinkId = [string]$currentDeviceLink.LinkId $deviceLinkForTarget = $currentDeviceLink if ($renewIdentity) { Reset-WindowsDeviceLinkFirmwareState -Confirm:$false | Out-Null $deviceLinkForTarget = Get-WindowsDeviceLink @runtime if ([string]::IsNullOrWhiteSpace([string]$deviceLinkForTarget.LinkId) -or [string]$deviceLinkForTarget.LinkId -ieq $previousLinkId) { throw 'Local firmware was reset, but Windows did not produce a new DeviceLink LinkId. No backend mutation was requested.' } } try { $reconcileParameters = @{ BackendUri = $BackendUri BackendApiKey = $plainKey InputObject = $deviceLinkForTarget TargetTenantId = $targetId TimeoutSeconds = $TimeoutSeconds } if (-not [string]::IsNullOrWhiteSpace($sourceId)) { $reconcileParameters.SourceTenantId = $sourceId } if ($decision -eq 'Repair') { $reconcileParameters.RepairExistingAssociation = $true } $reconcile = Invoke-WindowsDeviceLinkBackendReconcile @reconcileParameters } catch { if ($renewIdentity) { throw ("[WDL-BACKEND-RECONCILE-UNCERTAIN] The local DeviceLink identity was renewed, but backend reconciliation failed. Do not reset again or blindly retry; perform a fresh lookup. " + $_.Exception.Message) } throw } $verified = Invoke-WindowsDeviceLinkBackendLookup -BackendUri $BackendUri -BackendApiKey $plainKey -SerialNumber $deviceLinkForTarget.SerialNumber -TimeoutSeconds $TimeoutSeconds $verifiedMatches = @($verified.matches) $verifiedTenantRows = @($verified.tenants) $verifiedTenantIds = @($verifiedTenantRows | ForEach-Object { ([guid][string]$_.tenantId).ToString().ToLowerInvariant() } | Sort-Object) if ($verified.success -ne $true -or [int]$verified.failedTenantCount -ne 0 -or [int]$verified.successfulTenantCount -ne $catalogTenants.Count -or [int]$verified.searchedTenantCount -ne $catalogTenants.Count -or $verifiedTenantRows.Count -ne $catalogTenants.Count -or @($verifiedTenantRows | Where-Object { $_.success -ne $true }).Count -ne 0 -or @(Compare-Object $catalogIds $verifiedTenantIds).Count -ne 0 -or @($verified.tenantErrors).Count -ne 0 -or $verifiedMatches.Count -ne 1 -or [int]$verified.matchCount -ne 1 -or [string]$verifiedMatches[0].tenantId -ine $targetId -or [string]$verifiedMatches[0].serialNumber -ine [string]$deviceLinkForTarget.SerialNumber) { throw '[WDL-BACKEND-VERIFY-UNCERTAIN] Reconcile returned, but the final multitenant lookup did not prove exactly one target association. Perform a fresh lookup before another mutation.' } [pscustomobject]@{ PSTypeName='Windows.DeviceLink.TenantAssignmentResult'; Success=$true; Decision=$decision; Changed=$true; OperationMode='Backend' ReasonCode=if($decision -eq 'Move'){'WDL-BACKEND-MOVE'}elseif($decision -eq 'Repair'){'WDL-BACKEND-REPAIR'}else{'WDL-BACKEND-NEW'}; RetrySafe=$true; RecommendedAction='None' SerialNumber=[string]$deviceLinkForTarget.SerialNumber; SourceTenantId=$sourceId; TargetTenantId=$targetId TargetTenantName=$targetName; PreviousLinkId=$previousLinkId; NewLinkId=[string]$deviceLinkForTarget.LinkId AssociationId=[string]$verifiedMatches[0].associationId; AssociationState=[string]$verifiedMatches[0].associationState RequestId=[string]$reconcile.requestId Message=if($decision -eq 'Repair'){"DeviceLink association repair completed and was verified in '$targetName'."}else{"DeviceLink tenant assignment completed and was verified in '$targetName'."} } } finally { $plainKey=$null; $credential=$null } } |