🛑 The Crime Scene: A Misleading Desktop Failure

When modern lakehouse architectures meet desktop analytics clients, authentication breakdowns rarely announce their true root cause. Instead, engineers are handed generic hex codes and deceptive UI prompts.

During an enterprise deployment of Microsoft Fabric Warehouse, data analysts attempted to connect Tableau Desktop (2024.2.0) to the Fabric SQL/TDS endpoint (*.datawarehouse.fabric.microsoft.com) using the native Azure SQL Database connector and Microsoft Entra ID modern authentication.

Instead of opening the workspace schema and Delta tables, Tableau abruptly halted:

1
2
3
4
An error occurred while communicating with Azure SQL Database
Authentication failed.
Error Code: 84223ADA
User authorization failed (invalid_client)
flowchart LR
    A["Tableau Desktop
(Client UI)"] -->|"Azure SQL Connector
OAuth Request"| B["Microsoft Entra ID
(IDP)"] B --x|"Token Issuance Aborted"| A

Why the Client UI Is a Trap

In the OAuth 2.0 RFC 6749 specification, invalid_client typically indicates an unregistered client_id, a secret mismatch, or an unapproved redirect URI. In a traditional SaaS integration, an administrator might waste hours cycling client credentials or rebuilding app registrations.

However, in this environment:

  1. Interactive Delegated Auth: The connection relied on interactive user login, meaning client secret mismatches were completely irrelevant.
  2. Pre-TDS Failure: The connection aborted during token acquisition before TCP/TDS traffic ever reached the Microsoft Fabric endpoint. Workspace roles and Fabric access control policies hadn’t even been evaluated.

To find the true root cause, we have to stop troubleshooting the client UI and pivot directly into the Identity Provider’s telemetry stream.


🔍 Forensic Phase 1: Correlating Client Telemetry with Entra ID Logs

When a desktop client throws an ambiguous error, the authoritative source of truth is the Microsoft Entra ID SigninLogs stream.

Using Kusto Query Language (KQL) in Microsoft Sentinel / Azure Log Analytics, we correlate the exact timestamp of the failed connection attempt against the Tableau Desktop multi-tenant Application ID:

// Query 1: Trace the exact failure timestamp and correlation ID
let TargetUser = "analyst@organization.example";
let TableauAppId = "0464ea90-c12f-42a7-b347-c2311ca4413c"; // Public Tableau Desktop App ID

SigninLogs
| where TimeGenerated > ago(24h)
| where UserPrincipalName =~ TargetUser and AppId == TableauAppId
| project 
    TimeGenerated, 
    CorrelationId, 
    AppDisplayName, 
    AppId, 
    ResourceDisplayName, 
    ResourceServicePrincipalId, 
    ResultType, 
    ResultDescription, 
    ConditionalAccessStatus
| order by TimeGenerated desc

The Telemetry: The Real Dependency Surfaces

The KQL query reveals the exact reason the token issuance failed:

Telemetry FieldRaw Log Value
AppDisplayNameTableau Desktop
AppId0464ea90-c12f-42a7-b347-c2311ca4413c
ResultType650052 (AADSTS650052)
ResultDescriptionThe app needs access to a service (e9f49c6b-5ce5-44c8-925d-015017e9f7ad) that isn’t installed in your tenant.
CorrelationIda1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d

The invalid_client error was a red herring. The identity provider refused to issue tokens because the tenant directory was missing a required first-party service principal: e9f49c6b-5ce5-44c8-925d-015017e9f7ad.


🧩 Reverse Engineering the Undocumented OAuth Handshake

What is Application ID e9f49c6b-5ce5-44c8-925d-015017e9f7ad?

Querying the global Microsoft application catalog identifies this GUID as the first-party Azure Data Lake / Azure Storage enterprise application.

The Lakehouse Duality: Why Does TDS Require Azure Data Lake?

To understand why Tableau requested an Azure Data Lake token during an Azure SQL connection, we must examine how Microsoft Fabric decouples storage from compute:

sequenceDiagram
    autonumber
    actor User as BI Analyst
    participant Tableau as Tableau Desktop (2024.2.0)
    participant Entra as Microsoft Entra ID
    participant Fabric as Fabric SQL Endpoint (TDS)

    User->>Tableau: Initiate Fabric Warehouse Connection
    Tableau->>Entra: Parallel Token Request:
1. Azure SQL Database (022907d3...)
2. Azure Data Lake (e9f49c6b...) Note over Entra: Check local tenant directory for Service Principals Entra-->>Tableau: HTTP 400: AADSTS650052 (Azure Data Lake SP Missing) Tableau-->>User: Error 84223ADA: User authorization failed (invalid_client)
  1. The SQL/TDS Gateway: Microsoft Fabric exposes a TDS interface (port 1433) that mimics Azure SQL Database.
  2. OneLake Underlying Storage: The underlying data is stored as Delta Parquet files in OneLake (built on Azure Data Lake Storage Gen2).
  3. The Multi-Resource OAuth Profile: Tableau Desktop’s native driver architecture requests dual-resource delegated scopes during modern authentication—acquiring authorization for both the Azure SQL endpoint and the underlying storage subsystem simultaneously.

Because the target Entra ID tenant had never instantiated the Azure Data Lake enterprise application service principal, Entra ID aborted the OAuth handshake immediately.


⚡ Forensic Phase 2: Controlled Remediation & The “Progress Error”

With the missing resource identified, we instantiate the first-party Microsoft service principal using Microsoft Graph PowerShell:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Instantiate the missing first-party Azure Data Lake Service Principal
Connect-MgGraph -Scopes "Application.ReadWrite.All" -NoWelcome

$AzureDataLakeAppId = "e9f49c6b-5ce5-44c8-925d-015017e9f7ad"
$ExistingSP = Get-MgServicePrincipal -Filter "appId eq '$AzureDataLakeAppId'"

if (-not $ExistingSP) {
    New-MgServicePrincipal -AppId $AzureDataLakeAppId
    Write-Host "Instantiated Azure Data Lake Service Principal successfully." -ForegroundColor Green
} else {
    Write-Host "Service Principal already present." -ForegroundColor Yellow
}

Disconnect-MgGraph

The Second Error: Why Error Progression Equals Success

The analyst re-attempts the connection in Tableau Desktop. Once again, an error dialog appears. However, inspecting the fresh Entra ID sign-in log reveals an entirely different code:

SigninLogs
| where TimeGenerated > ago(1h)
| where UserPrincipalName =~ TargetUser and AppId == TableauAppId
| project TimeGenerated, AppDisplayName, ResultType, ResultDescription
| order by TimeGenerated desc
ResultTypeResultDescription
90095 (AADSTS90095)Admin consent is required for the requested permissions on Tableau Desktop.

[!TIP] The Identity Forensics Rule: In distributed identity troubleshooting, a new error code is definitive proof of forward progress.

  • 650052: The identity provider could not locate the required resource definition.
  • 90095: The resource was successfully resolved, and execution successfully advanced to the tenant’s admin consent and authorization policy evaluation.
stateDiagram-v2
    [*] --> Phase1: Connect Attempt
    Phase1 --> Phase2: Instantiate Azure Data Lake SP
    Phase2 --> Phase3: Admin Consent Review
    Phase3 --> Success: ResultType 0 (TDS Connected)

    state Phase1 {
        Tableau_84223ADA --> Entra_650052 : Missing Service Principal
    }
    state Phase2 {
        Entra_90095 : Admin Consent Gateway Triggered
    }
    state Phase3 {
        Entra_Consent : Delegated Scope Granted
    }
    state Success {
        Connected : Authenticated & Fabric RBAC Evaluated
    }

Once a tenant administrator reviews the generated consent request and grants delegated permissions for the Tableau Desktop application, Entra ID issues the access tokens. The connection succeeds with ResultType: 0.


🛡️ Engineering the Defensible DevSecOps Runbook

Ad-hoc fixes in the cloud console solve single incidents, but enterprise environments demand automated, hardened, and repeatable runbooks.

Unchecked administrative scripting introduces real operational risks:

  • Elevated Session Leakage: Leaving interactive write scopes (Application.ReadWrite.All) open in developer shells.
  • Cross-Tenant Misconfiguration: Accidentally executing modifications against the wrong tenant in multi-tenant MSP or hybrid environments.
  • CSV Formula Injection (CWE-1236): Unsanitized log exports containing spreadsheet command triggers (=, +, -, @) that can execute arbitrary payloads when opened by audit teams in Microsoft Excel.

Hardened Remediation and Audit Script

The following script encapsulates our reverse-engineered resolution with strict defensive boundaries:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
<#
.SYNOPSIS
    Hardened diagnostic and remediation script for Tableau-to-Fabric OAuth dependencies.
.DESCRIPTION
    Validates tenant context, inventories service principals, applies JIT remediation,
    and exports formula-injection-safe evidence.
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory = $true)]
    [string]$ExpectedTenantId,

    [Parameter(Mandatory = $true)]
    [string]$EvidenceOutputPath
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$TableauAppId       = '0464ea90-c12f-42a7-b347-c2311ca4413c'
$AzureDataLakeAppId = 'e9f49c6b-5ce5-44c8-925d-015017e9f7ad'

# 1. Read-Only Pre-flight & Tenant Boundary Check
Write-Host "[1/4] Connecting to Microsoft Graph (Read-Only)..." -ForegroundColor Cyan
Disconnect-MgGraph -ErrorAction SilentlyContinue
Connect-MgGraph -Scopes 'Application.Read.All' -NoWelcome

$Context = Get-MgContext
if (-not $Context -or $Context.TenantId -ne $ExpectedTenantId) {
    Disconnect-MgGraph -ErrorAction SilentlyContinue
    throw "Security Guardrail: Connected Tenant ID does not match expected target ($ExpectedTenantId). Aborting."
}

# 2. Inventory Required Service Principals
$Targets = @(
    @{ Name = 'Tableau Desktop'; AppId = $TableauAppId },
    @{ Name = 'Azure Data Lake'; AppId = $AzureDataLakeAppId }
)

$Inventory = foreach ($Target in $Targets) {
    $Sp = Get-MgServicePrincipal -Filter "appId eq '$($Target.AppId)'" -ErrorAction SilentlyContinue
    [pscustomobject]@{
        Name           = $Target.Name
        AppId          = $Target.AppId
        ExistsInTenant = ($null -ne $Sp)
        ObjectId       = $Sp.Id
        AccountEnabled = $Sp.AccountEnabled
    }
}
$Inventory | Format-Table -AutoSize
Disconnect-MgGraph

# 3. Privileged Remediation (JIT Session with Guaranteed Teardown)
$NeedsDataLakeSp = ($Inventory | Where-Object { $_.AppId -eq $AzureDataLakeAppId -and -not $_.ExistsInTenant })

if ($NeedsDataLakeSp) {
    Write-Host "[2/4] Instantiating missing Azure Data Lake Service Principal..." -ForegroundColor Yellow
    Connect-MgGraph -Scopes 'Application.ReadWrite.All' -NoWelcome
    try {
        $CurrentContext = Get-MgContext
        if ($CurrentContext.TenantId -ne $ExpectedTenantId) {
            throw "Tenant mismatch during privileged session."
        }
        $CreatedSp = New-MgServicePrincipal -AppId $AzureDataLakeAppId -ErrorAction Stop
        Write-Host "Created SP successfully: ObjectId $($CreatedSp.Id)" -ForegroundColor Green
    }
    finally {
        Write-Host "[3/4] Tearing down privileged Graph session..." -ForegroundColor Cyan
        Disconnect-MgGraph -ErrorAction SilentlyContinue
    }
} else {
    Write-Host "[2/4] Azure Data Lake SP already present. No write action needed." -ForegroundColor Green
}

# 4. Safe Evidence Packaging (Formula Injection Defense - CWE-1236)
function ConvertTo-SafeSpreadsheetText {
    param([AllowNull()][object]$Value)
    if ($null -eq $Value) { return $null }
    $Text = [string]$Value
    if ($Text -match '^[=+@-]') { return "'$Text" }
    return $Text
}

Write-Host "[4/4] Generating sanitized audit evidence..." -ForegroundColor Cyan
if (-not (Test-Path -LiteralPath $EvidenceOutputPath -PathType Container)) {
    New-Item -ItemType Directory -LiteralPath $EvidenceOutputPath -Force | Out-Null
}

$SanitizedInventory = $Inventory | ForEach-Object {
    [pscustomobject]@{
        Name           = ConvertTo-SafeSpreadsheetText $_.Name
        AppId          = ConvertTo-SafeSpreadsheetText $_.AppId
        ExistsInTenant = $_.ExistsInTenant
        ObjectId       = ConvertTo-SafeSpreadsheetText $_.ObjectId
    }
}

$CsvPath = Join-Path $EvidenceOutputPath 'Tableau-Fabric-SP-Inventory.csv'
$SanitizedInventory | Export-Csv -LiteralPath $CsvPath -NoTypeInformation -Encoding UTF8

# Cryptographic Evidence Seal
Get-ChildItem -LiteralPath $EvidenceOutputPath -File | 
    Get-FileHash -Algorithm SHA256 | 
    Export-Csv -LiteralPath (Join-Path $EvidenceOutputPath 'SHA256SUMS.csv') -NoTypeInformation -Encoding UTF8

Write-Host "Audit evidence cryptographically sealed at $EvidenceOutputPath" -ForegroundColor Green

📊 Non-Dropping KQL: Auditing Conditional Access Without Telemetry Gaps

When investigating authentication failures across managed and BYOD devices, many standard KQL queries use naive mv-expand on ConditionalAccessPolicies. If a sign-in event triggered no CA policies, or if device trust was missing, standard inner expansions drop those rows entirely from the audit report.

The following production query uses a left-outer expansion pattern to ensure every sign-in attempt is accounted for:

let BYODPolicyName = "Example BYOD Web Access Controls";
let BaseSignIns = materialize(
    SigninLogs
    | where TimeGenerated > ago(30d)
    | where AppId == "0464ea90-c12f-42a7-b347-c2311ca4413c" // Tableau Desktop
    | extend 
        TrustType = tostring(DeviceDetail.trustType),
        OS = tostring(DeviceDetail.operatingSystem),
        IsBrowser = (ClientAppUsed =~ "Browser")
    | project 
        TimeGenerated, 
        CorrelationId, 
        UserPrincipalName, 
        AppDisplayName, 
        ClientAppUsed, 
        IsBrowser, 
        TrustType, 
        OS, 
        ConditionalAccessPolicies, 
        ResultType
);

let PolicyResults = BaseSignIns
    | mv-expand kind=left CAPolicy = ConditionalAccessPolicies
    | extend CAName = tostring(CAPolicy.displayName), CAResult = tostring(CAPolicy.result)
    | where CAName == BYODPolicyName
    | summarize PolicyResult = strcat_array(make_set(CAResult), ",") by CorrelationId;

BaseSignIns
| project-away ConditionalAccessPolicies
| join kind=leftouter PolicyResults on CorrelationId
| extend 
    PolicyEnforced = isnotempty(PolicyResult),
    EffectiveResult = iff(isnotempty(PolicyResult), PolicyResult, "No matching policy entry")
| summarize TotalAttempts = count(), SuccessfulAuths = countif(ResultType == 0) 
    by UserPrincipalName, AppDisplayName, TrustType, EffectiveResult
| order by TotalAttempts desc

🎯 Key Takeaways for Identity & Data Engineers

  1. Client Error Codes Mask Protocol Truth: A client-side invalid_client or 84223ADA error is merely an HTTP 400 wrapper. Always trace the session via CorrelationId in the Identity Provider’s sign-in logs before modifying configurations.
  2. Modern Lakehouses Abstract Storage, Not Identity: Even when connecting over a standard TDS/SQL gateway, modern analytics drivers frequently require explicit delegated token scopes for underlying storage resources (Azure Data Lake).
  3. Embrace Error Progression: Moving from AADSTS650052 (missing resource) → AADSTS90095 (admin consent) → 0 (success) confirms that each identity layer was resolved systematically.
  4. Build Defensible Runbooks: Separate read-only diagnostics from privileged writes, enforce programmatic tenant boundaries, and protect audit artifacts against spreadsheet injection.