Public/Remove-WindowsDeviceLinkAssociation.ps1

function Remove-WindowsDeviceLinkAssociation {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    param(
        [ValidateNotNullOrEmpty()]
        [string]$AssociationId,

        [ValidateNotNullOrEmpty()]
        [string]$SerialNumber,

        [Parameter(Mandatory)]
        [ValidateSet(
            'DeviceCode',
            'Interactive',
            'ClientSecret',
            'AccessToken',
            'Certificate',
            'CertificateThumbprint',
            'CertificateSubjectName',
            'EnvironmentVariable',
            'ManagedIdentity'
        )]
        [string]$Method,

        [ValidateNotNullOrEmpty()]
        [string]$TenantId,

        [ValidateNotNullOrEmpty()]
        [string]$ClientId,

        [securestring]$AccessToken,

        [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,

        [ValidateNotNullOrEmpty()]
        [string]$CertificateThumbprint,

        [ValidateNotNullOrEmpty()]
        [string]$CertificateSubjectName,

        [bool]$SendCertificateChain = $false,

        [securestring]$ClientSecret,

        [ValidateNotNullOrEmpty()]
        [string]$Environment = 'Global',

        [ValidateRange(1, 600)]
        [double]$ClientTimeout = 100
    )

    if ([string]::IsNullOrWhiteSpace($AssociationId) -eq [string]::IsNullOrWhiteSpace($SerialNumber)) {
        throw 'Specify exactly one of -AssociationId or -SerialNumber.'
    }

    $allowedByMethod = @{
        DeviceCode = @('TenantId','ClientId')
        Interactive = @('TenantId','ClientId')
        ClientSecret = @('TenantId','ClientId','ClientSecret')
        AccessToken = @('TenantId','AccessToken')
        Certificate = @('TenantId','ClientId','Certificate','SendCertificateChain')
        CertificateThumbprint = @('TenantId','ClientId','CertificateThumbprint','SendCertificateChain')
        CertificateSubjectName = @('TenantId','ClientId','CertificateSubjectName','SendCertificateChain')
        EnvironmentVariable = @()
        ManagedIdentity = @('ClientId')
    }

    $methodSpecificParameters = @(
        'TenantId','ClientId','AccessToken','Certificate','CertificateThumbprint',
        'CertificateSubjectName','SendCertificateChain','ClientSecret'
    )

    $invalidParameters = $methodSpecificParameters | Where-Object {
        $PSBoundParameters.ContainsKey($_) -and $_ -notin $allowedByMethod[$Method]
    }
    if ($invalidParameters) {
        throw "The following parameters are not valid with -Method $Method`: $($invalidParameters -join ', ')."
    }

    switch ($Method) {
        'DeviceCode' {
            if (-not $TenantId) { throw '-TenantId is required for -Method DeviceCode.' }
        }
        'Interactive' {
            if (-not $TenantId) { throw '-TenantId is required for -Method Interactive.' }
        }
        'ClientSecret' {
            if (-not $TenantId -or -not $ClientId -or -not $PSBoundParameters.ContainsKey('ClientSecret')) {
                throw '-TenantId, -ClientId, and -ClientSecret are required for -Method ClientSecret.'
            }
        }
        'AccessToken' {
            if (-not $TenantId -or -not $PSBoundParameters.ContainsKey('AccessToken')) {
                throw '-TenantId and -AccessToken are required for -Method AccessToken.'
            }
        }
        'Certificate' {
            if (-not $TenantId -or -not $ClientId -or -not $Certificate) {
                throw '-TenantId, -ClientId, and -Certificate are required for -Method Certificate.'
            }
        }
        'CertificateThumbprint' {
            if (-not $TenantId -or -not $ClientId -or -not $CertificateThumbprint) {
                throw '-TenantId, -ClientId, and -CertificateThumbprint are required for -Method CertificateThumbprint.'
            }
        }
        'CertificateSubjectName' {
            if (-not $TenantId -or -not $ClientId -or -not $CertificateSubjectName) {
                throw '-TenantId, -ClientId, and -CertificateSubjectName are required for -Method CertificateSubjectName.'
            }
        }
        'EnvironmentVariable' {
            $missingVariables = @('AZURE_TENANT_ID','AZURE_CLIENT_ID','AZURE_CLIENT_SECRET') | Where-Object {
                -not [Environment]::GetEnvironmentVariable($_)
            }
            if ($missingVariables) {
                throw "-Method EnvironmentVariable requires environment variables: $($missingVariables -join ', ')."
            }
        }
    }

    $baseUri = 'https://graph.microsoft.com/beta'
    $resolvedAssociationId = $AssociationId
    $resolvedSerialNumber = $SerialNumber
    $nativeAccessToken = $null
    $sdkMode = $false

    if ($Method -in @('DeviceCode','ClientSecret','EnvironmentVariable','AccessToken')) {
        if ($Environment -ne 'Global') {
            throw 'Native association removal currently supports the Global Microsoft cloud only.'
        }

        switch ($Method) {
            'DeviceCode' {
                $tokenParameters = @{ TenantId = $TenantId }
                if ($ClientId) { $tokenParameters.ClientId = $ClientId }
                Write-Information -InformationAction Continue -MessageData 'Using native OAuth device-code authentication for Device Association removal.'
                $token = Get-WindowsDeviceLinkDeviceCodeToken @tokenParameters
                $nativeAccessToken = $token.AccessToken
            }
            'ClientSecret' {
                Write-Information -InformationAction Continue -MessageData 'Using native OAuth client-credentials authentication for Device Association removal.'
                $token = Get-WindowsDeviceLinkClientSecretToken -TenantId $TenantId -ClientId $ClientId -ClientSecret $ClientSecret
                $nativeAccessToken = $token.AccessToken
            }
            'EnvironmentVariable' {
                $environmentSecret = ConvertTo-SecureString $env:AZURE_CLIENT_SECRET -AsPlainText -Force
                Write-Information -InformationAction Continue -MessageData 'Using native OAuth client-credentials authentication from environment variables for Device Association removal.'
                $token = Get-WindowsDeviceLinkClientSecretToken `
                    -TenantId $env:AZURE_TENANT_ID `
                    -ClientId $env:AZURE_CLIENT_ID `
                    -ClientSecret $environmentSecret
                $nativeAccessToken = $token.AccessToken
                $TenantId = $env:AZURE_TENANT_ID
            }
            'AccessToken' {
                $credential = New-Object System.Management.Automation.PSCredential('token', $AccessToken)
                $nativeAccessToken = $credential.GetNetworkCredential().Password
                $credential = $null
            }
        }
    }
    else {
        $connectParams = @{
            Environment   = $Environment
            ClientTimeout = $ClientTimeout
        }

        switch ($Method) {
            'Interactive' {
                $connectParams.TenantId = $TenantId
                if ($ClientId) { $connectParams.ClientId = $ClientId }
            }
            'Certificate' {
                $connectParams.TenantId = $TenantId
                $connectParams.ClientId = $ClientId
                $connectParams.Certificate = $Certificate
                $connectParams.SendCertificateChain = $SendCertificateChain
            }
            'CertificateThumbprint' {
                $connectParams.TenantId = $TenantId
                $connectParams.ClientId = $ClientId
                $connectParams.CertificateThumbprint = $CertificateThumbprint
                $connectParams.SendCertificateChain = $SendCertificateChain
            }
            'CertificateSubjectName' {
                $connectParams.TenantId = $TenantId
                $connectParams.ClientId = $ClientId
                $connectParams.CertificateSubjectName = $CertificateSubjectName
                $connectParams.SendCertificateChain = $SendCertificateChain
            }
            'ManagedIdentity' {
                $connectParams.Identity = $true
                if ($ClientId) { $connectParams.ClientId = $ClientId }
            }
        }

        Connect-WindowsDeviceLink @connectParams | Out-Null
        $sdkMode = $true
    }

    try {
        if (-not $resolvedAssociationId) {
            $escapedSerial = $SerialNumber.Replace("'", "''")
            $filter = [uri]::EscapeDataString("serialNumber eq '$escapedSerial'")
            $lookupUri = "$baseUri/deviceManagement/tenantAssociatedDevices?`$filter=$filter"

            if ($sdkMode) {
                $lookup = Invoke-MgGraphRequest -Method GET -Uri $lookupUri -ErrorAction Stop
            }
            else {
                $lookup = Invoke-RestMethod -Method GET -Uri $lookupUri -Headers @{ Authorization = "Bearer $nativeAccessToken" } -ErrorAction Stop
            }

            $matches = @($lookup.value)

            if ($matches.Count -eq 0) {
                Write-Information -InformationAction Continue -MessageData 'Filtered serial-number lookup returned no match; retrying with client-side matching.'

                $allRecords = @()
                $nextUri = "$baseUri/deviceManagement/tenantAssociatedDevices"
                while ($nextUri) {
                    if ($sdkMode) {
                        $page = Invoke-MgGraphRequest -Method GET -Uri $nextUri -ErrorAction Stop
                    }
                    else {
                        $page = Invoke-RestMethod -Method GET -Uri $nextUri -Headers @{ Authorization = "Bearer $nativeAccessToken" } -ErrorAction Stop
                    }

                    if ($page.value) { $allRecords += @($page.value) }
                    $nextUri = $page.'@odata.nextLink'
                }

                $matches = @($allRecords | Where-Object { [string]$_.serialNumber -eq [string]$SerialNumber })
            }

            if ($matches.Count -eq 0) {
                throw "No Device Association record was found for serial number '$SerialNumber'."
            }
            if ($matches.Count -gt 1) {
                throw "Multiple Device Association records were found for serial number '$SerialNumber'. Use -AssociationId to select the exact record."
            }

            $resolvedAssociationId = [string]$matches[0].id
            $resolvedSerialNumber = [string]$matches[0].serialNumber
        }

        $deleteUri = "$baseUri/deviceManagement/tenantAssociatedDevices/$resolvedAssociationId"
        $target = if ($resolvedSerialNumber) { "$resolvedSerialNumber ($resolvedAssociationId)" } else { $resolvedAssociationId }

        if ($PSCmdlet.ShouldProcess($target, 'Remove Device Association record')) {
            try {
                if ($sdkMode) {
                    Invoke-MgGraphRequest -Method DELETE -Uri $deleteUri -ErrorAction Stop | Out-Null
                }
                else {
                    Invoke-RestMethod -Method DELETE -Uri $deleteUri -Headers @{ Authorization = "Bearer $nativeAccessToken" } -ErrorAction Stop | Out-Null
                }
            }
            catch {
                $statusCode = $null
                try { $statusCode = [int]$_.Exception.Response.StatusCode } catch {}
                if ($statusCode -eq 404) {
                    throw "Device Association '$resolvedAssociationId' was not found or has already been removed."
                }
                throw
            }

            Write-Information -InformationAction Continue -MessageData "Device Association removed successfully. Association ID: $resolvedAssociationId"

            [pscustomobject]@{
                PSTypeName    = 'Windows.DeviceLink.AssociationRemovalResult'
                AssociationId = $resolvedAssociationId
                SerialNumber  = $resolvedSerialNumber
                TenantId      = $TenantId
                Removed       = $true
            }
        }
    }
    finally {
        $nativeAccessToken = $null
    }
}