Step 1: Azure & Function App

Create Azure resources, deploy the Function App ingestion script, and secure it with Entra ID authentication.

1. Azure Resource Setup

1.1 Create Storage Account

Azure CLI
# Create resource group
az group create --name rg-mde-toolkit --location eastus

# Create storage account
az storage account create \
  --name stmdetoolkit \
  --resource-group rg-mde-toolkit \
  --sku Standard_LRS \
  --kind StorageV2

# Create the table (optional — the Function App auto-creates it)
az storage table create \
  --name HealthReports \
  --account-name stmdetoolkit

1.2 Create Function App

Azure CLI
# Create Function App (PowerShell 7.4 runtime)
az functionapp create \
  --name func-mde-toolkit \
  --resource-group rg-mde-toolkit \
  --storage-account stmdetoolkit \
  --consumption-plan-location eastus \
  --runtime powershell \
  --runtime-version 7.4 \
  --functions-version 4 \
  --os-type Windows

# Enable system-assigned Managed Identity
az functionapp identity assign \
  --name func-mde-toolkit \
  --resource-group rg-mde-toolkit

# Set app settings
az functionapp config appsettings set \
  --name func-mde-toolkit \
  --resource-group rg-mde-toolkit \
  --settings \
    StorageAccountName=stmdetoolkit \
    StorageTableName=HealthReports \
    AzureEnvironment=Public

1.3 Grant Table Storage Access

The Function App's Managed Identity needs Storage Table Data Contributor on the storage account:

Azure CLI
# Get the Function App's principal ID
PRINCIPAL_ID=$(az functionapp identity show \
  --name func-mde-toolkit \
  --resource-group rg-mde-toolkit \
  --query principalId -o tsv)

# Get the storage account resource ID
STORAGE_ID=$(az storage account show \
  --name stmdetoolkit \
  --resource-group rg-mde-toolkit \
  --query id -o tsv)

# Assign Storage Table Data Contributor
az role assignment create \
  --assignee $PRINCIPAL_ID \
  --role "Storage Table Data Contributor" \
  --scope $STORAGE_ID

1.4 Register Entra ID App (Function App Audience)

Create an App Registration so endpoints can acquire a bearer token scoped to the Function App:

Azure CLI
# Create app registration
az ad app create \
  --display-name "MDE Toolkit Function" \
  --identifier-uris "api://mde-toolkit-func" \
  --sign-in-audience AzureADMyOrg

# Note the Application (client) ID — this becomes the FunctionAppAudience
# registry value: api://mde-toolkit-func

2. Function App Deployment

The Function App is a single PowerShell HTTP trigger that receives the JSON snapshot from each endpoint, flattens it, and writes it to the configured backend.

2.1 Project Structure

File Layout
FunctionApp/
├── host.json
├── requirements.psd1
├── profile.ps1
└── HealthReportIngestion/
    ├── function.json          # HTTP POST trigger binding
    └── run.ps1                # Ingestion logic

2.2 function.json

FunctionApp/HealthReportIngestion/function.json
{
  "bindings": [
    {
      "authLevel": "anonymous",
      "type": "httpTrigger",
      "direction": "in",
      "name": "Request",
      "methods": ["post"]
    },
    {
      "type": "http",
      "direction": "out",
      "name": "Response"
    }
  ]
}
Auth level is "anonymous" because authentication is handled by the Entra ID Identity Provider configured on the Function App (see Security section below), not by a function key.

2.3 Ingestion Scripts

Each destination has its own run.ps1. Click the tabs below to view the script for your target backend.

The default and production-tested destination. Upserts a flat entity into Azure Table Storage with ~80 typed columns for Power BI.

run.ps1 — Azure Table Storage
using namespace System.Net

param($Request, $TriggerMetadata)

Write-Host "Health report ingestion request received"

try {
    $requestBody = $Request.Body
    if (-not $requestBody) {
        Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
            StatusCode = [HttpStatusCode]::BadRequest
            Body = "Request body is empty"
        })
        return
    }

    if ($requestBody -is [string]) {
        $report = $requestBody | ConvertFrom-Json -Depth 20
        $rawJson = $requestBody
    } else {
        $report = $requestBody
        $rawJson = $report | ConvertTo-Json -Depth 20 -Compress
    }

    # Helpers
    function Coalesce { param($a, $b) if ($null -ne $a -and $a -ne '') { $a } else { $b } }
    function CoalesceBool { param($a, $b) if ($a) { [bool]$a } elseif ($b) { [bool]$b } else { $false } }
    function CoalesceInt { param($a, $b) if ($a) { [int]$a } elseif ($b) { [int]$b } else { 0 } }
    function CoalesceStr { param($a, $b, $d) $v = Coalesce $a $b; if ($v) { [string]$v } else { $d } }

    # Device info from headers or report body
    $hostname = if ($Request.Headers["X-Device-Hostname"]) { $Request.Headers["X-Device-Hostname"] }
                elseif ($report.hostname) { $report.hostname }
                elseif ($report.machineName) { $report.machineName } else { "unknown" }
    $organizationId = if ($Request.Headers["X-Organization-Id"]) { $Request.Headers["X-Organization-Id"] }
                      elseif ($report.organizationId) { $report.organizationId } else { "default" }
    $deviceGroup = if ($Request.Headers["X-Device-Group"]) { $Request.Headers["X-Device-Group"] }
                   else { $report.deviceGroup }
    $reportId = if ($report.reportId) { $report.reportId } else { [Guid]::NewGuid().ToString() }
    $generatedAt = if ($report.generatedAtUtc) {
        if ($report.generatedAtUtc -is [datetime]) { $report.generatedAtUtc.ToUniversalTime().ToString("o") }
        else { [string]$report.generatedAtUtc }
    } elseif ($report.collectedUtc) {
        if ($report.collectedUtc -is [datetime]) { $report.collectedUtc.ToUniversalTime().ToString("o") }
        else { [string]$report.collectedUtc }
    } else { (Get-Date).ToUniversalTime().ToString("o") }
    $uploadedAt = (Get-Date).ToUniversalTime().ToString("o")

    # Build flat entity for Table Storage / Power BI
    $entity = @{
        PartitionKey = $organizationId
        RowKey = "${hostname}_${reportId}"
        GeneratedAtUtc = $generatedAt; UploadedAtUtc = $uploadedAt
        ReportId = $reportId; Hostname = $hostname; DeviceGroup = $deviceGroup
        OrganizationId = $organizationId
        OsVersion = Coalesce $report.osVersion $report.osBuild
        OsBuild = $report.osBuild; DomainName = $report.domainName
        IpAddress = $report.ipAddress; LastBootTimeUtc = $report.lastBootTimeUtc
        IsAzureAdJoined = CoalesceBool $report.isAzureAdJoined $null
        IsHybridJoined = CoalesceBool $report.isHybridJoined $null
        AzureAdDeviceId = $report.azureAdDeviceId
        AzureAdTenantId = $report.azureAdTenantId; DeviceTrustType = $report.deviceTrustType
        HealthScore = CoalesceInt $report.healthScore $null
        OverallStatus = CoalesceStr $report.overallStatus $null "Unknown"
        CriticalEventCount = CoalesceInt $report.criticalEventCount $null
        TopIssues = if ($report.topIssues) { $report.topIssues -join "; " } else { $null }
        DefenderEnabled = CoalesceBool $report.defenderEnabled $null
        RealTimeProtectionEnabled = CoalesceBool $report.realTimeProtectionEnabled $null
        BehaviorMonitorEnabled = CoalesceBool $report.behaviorMonitorEnabled $null
        DefenderAvStatus = CoalesceStr $report.defenderAvStatus $null "Unknown"
        SignatureAgeHours = CoalesceInt $report.signatureAgeHours $null
        IsOnboarded = CoalesceBool $report.isOnboarded $null
        SenseIsRunning = CoalesceBool $report.senseIsRunning $null
        TamperProtectionEnabled = CoalesceBool $report.tamperProtectionEnabled $null
        MdeStatus = CoalesceStr $report.mdeStatus $null "Unknown"
        AsrIsConfigured = CoalesceBool $report.asrIsConfigured $report.asr.isConfigured
        AsrTotalRules = CoalesceInt $report.asrTotalRules $report.asr.totalRules
        AsrBlockModeRules = CoalesceInt $report.asrBlockModeRules $report.asr.blockModeRules
        AsrStatus = CoalesceStr $report.asrStatus $report.asr.status "Unknown"
        FirewallDomainEnabled = CoalesceBool $report.firewallDomainEnabled $null
        FirewallPrivateEnabled = CoalesceBool $report.firewallPrivateEnabled $null
        FirewallPublicEnabled = CoalesceBool $report.firewallPublicEnabled $null
        FirewallStatus = CoalesceStr $report.firewallStatus $null "Unknown"
        DeviceControlEnabled = CoalesceBool $report.deviceControlEnabled $report.deviceControl.isEnabled
        DeviceControlStatus = CoalesceStr $report.deviceControlStatus $report.deviceControl.status "Unknown"
        DeviceControlRuleCount = CoalesceInt $report.deviceControlRuleCount 0
        AppControlIsConfigured = CoalesceBool $report.appControlIsConfigured $report.appControl.isConfigured
        AppControlIsEnabled = CoalesceBool $report.appControlIsEnabled $report.appControl.isEnabled
        AppControlStatus = CoalesceStr $report.appControlStatus $report.appControl.status "Unknown"
        VbsEnabled = CoalesceBool $report.vbsEnabled $report.vbs.vbsEnabled
        HvciRunning = CoalesceBool $report.hvciRunning $report.vbs.hvciRunning
        CredentialGuardRunning = CoalesceBool $report.credentialGuardRunning $report.vbs.credentialGuardRunning
        VbsStatus = CoalesceStr $report.vbsStatus $report.vbs.status "Unknown"
        SchemaVersion = CoalesceInt $report.schemaVersion $null
        ServiceVersion = Coalesce $report.collectorVersion $report.clientConfig.serviceVersion
        ReportJson = $null
    }

    # Slim ReportJson to fit Table Storage 64KB limit
    try {
        $slim = $report.PSObject.Copy()
        foreach ($p in @('defenderPolicies','firewallProfiles','deviceGuardStatus')) {
            if ($slim.PSObject.Properties[$p]) { $slim.PSObject.Properties.Remove($p) }
        }
        $j = $slim | ConvertTo-Json -Depth 10 -Compress
        if ($j.Length -gt 30000) { $j = $j.Substring(0, 30000) + '..."TRUNCATED"}' }
        $entity.ReportJson = $j
    } catch { $entity.ReportJson = '{"error":"JSON too large"}' }

    # Storage config
    $sa = $env:StorageAccountName
    $tn = if ($env:StorageTableName) { $env:StorageTableName } else { "HealthReports" }
    $ae = if ($env:AzureEnvironment) { $env:AzureEnvironment } else { "Public" }
    $res = "https://storage.azure.com"
    $sfx = switch ($ae) { "Government" { "core.usgovcloudapi.net" }; "China" { "core.chinacloudapi.cn" }; default { "core.windows.net" } }
    $base = "https://${sa}.table.${sfx}"

    # Managed Identity token
    $tok = (Invoke-RestMethod -Uri "$($env:IDENTITY_ENDPOINT)?resource=${res}&api-version=2019-08-01" `
        -Headers @{"X-IDENTITY-HEADER"=$env:IDENTITY_HEADER} -Method Get).access_token
    $h = @{ Authorization="Bearer $tok"; "Content-Type"="application/json"; Accept="application/json;odata=nometadata"; "x-ms-version"="2020-12-06"; "x-ms-date"=[DateTime]::UtcNow.ToString("R") }

    # Create table if needed
    try { Invoke-RestMethod -Uri "${base}/Tables" -Method Post -Headers $h -Body (@{TableName=$tn}|ConvertTo-Json) } catch {}

    # Build OData entity with type annotations
    $oe = @{ PartitionKey=$entity.PartitionKey; RowKey=$entity.RowKey }
    foreach ($k in $entity.Keys) {
        if ($k -ne "PartitionKey" -and $k -ne "RowKey" -and $null -ne $entity[$k]) {
            $v = $entity[$k]
            if ($v -is [bool]) { $oe[$k]=$v; $oe["$k@odata.type"]="Edm.Boolean" }
            elseif ($v -is [int]) { $oe[$k]=$v; $oe["$k@odata.type"]="Edm.Int32" }
            else { $oe[$k]=[string]$v }
        }
    }

    # Upsert
    $pk = [System.Web.HttpUtility]::UrlEncode($entity.PartitionKey)
    $rk = [System.Web.HttpUtility]::UrlEncode($entity.RowKey)
    $h2 = @{ Authorization="Bearer $tok"; "Content-Type"="application/json"; Accept="application/json;odata=nometadata"; "x-ms-version"="2020-12-06"; "x-ms-date"=[DateTime]::UtcNow.ToString("R") }
    Invoke-RestMethod -Uri "${base}/${tn}(PartitionKey='${pk}',RowKey='${rk}')" -Method Put -Headers $h2 -Body ($oe|ConvertTo-Json -Depth 10 -Compress)

    Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
        StatusCode = [HttpStatusCode]::OK
        Headers = @{"Content-Type"="application/json"}
        Body = (@{ success=$true; reportId=$reportId; hostname=$hostname; healthScore=$entity.HealthScore } | ConvertTo-Json)
    })
} catch {
    Write-Error "Error: $_"
    Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
        StatusCode=[HttpStatusCode]::InternalServerError; Body="Error: $_"
    })
}
⚠️ Not Tested. Review and test thoroughly before deploying.

Writes the full JSON snapshot as a blob. Useful for Azure Data Factory, Logic Apps, or custom pipelines.

Required: StorageAccountName, BlobContainerName, Storage Blob Data Contributor role.

run.ps1 — Blob Storage (Not Tested)
using namespace System.Net
param($Request, $TriggerMetadata)

try {
    $body = $Request.Body
    if (-not $body) { Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{StatusCode=[HttpStatusCode]::BadRequest;Body="Empty"}); return }
    if ($body -is [string]) { $report=$body|ConvertFrom-Json -Depth 20; $raw=$body } else { $report=$body; $raw=$body|ConvertTo-Json -Depth 20 -Compress }

    $host_ = if ($Request.Headers["X-Device-Hostname"]) {$Request.Headers["X-Device-Hostname"]} elseif ($report.hostname) {$report.hostname} else {"unknown"}
    $rid = if ($report.reportId) {$report.reportId} else {[Guid]::NewGuid().ToString()}
    $ts = (Get-Date).ToUniversalTime().ToString("yyyyMMdd-HHmmss")

    $sa = $env:StorageAccountName; $cn = if ($env:BlobContainerName) {$env:BlobContainerName} else {"mde-telemetry"}
    $ae = if ($env:AzureEnvironment) {$env:AzureEnvironment} else {"Public"}
    $sfx = switch ($ae) {"Government"{"blob.core.usgovcloudapi.net"};"China"{"blob.core.chinacloudapi.cn"};default{"blob.core.windows.net"}}

    $tok = (Invoke-RestMethod -Uri "$($env:IDENTITY_ENDPOINT)?resource=https://storage.azure.com&api-version=2019-08-01" -Headers @{"X-IDENTITY-HEADER"=$env:IDENTITY_HEADER} -Method Get).access_token
    $bn = "${host_}/${ts}_${rid}.json"
    Invoke-RestMethod -Uri "https://${sa}.${sfx}/${cn}/${bn}" -Method Put -Headers @{Authorization="Bearer $tok";"x-ms-blob-type"="BlockBlob";"Content-Type"="application/json";"x-ms-version"="2020-12-06";"x-ms-date"=[DateTime]::UtcNow.ToString("R")} -Body $raw

    Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{StatusCode=[HttpStatusCode]::OK;Headers=@{"Content-Type"="application/json"};Body=(@{success=$true;blobName=$bn;hostname=$host_}|ConvertTo-Json)})
} catch { Write-Error "Error: $_"; Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{StatusCode=[HttpStatusCode]::InternalServerError;Body="Error: $_"}) }
⚠️ Not Tested. Uses the legacy Data Collector API. For new workspaces consider DCR-based ingestion.

Sends health reports to Log Analytics → MDEToolkitHealth_CL.

Required: WorkspaceId, WorkspaceKey

run.ps1 — Sentinel / Log Analytics (Not Tested)
using namespace System.Net
param($Request, $TriggerMetadata)

try {
    $body = $Request.Body
    if (-not $body) { Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{StatusCode=[HttpStatusCode]::BadRequest;Body="Empty"}); return }
    if ($body -is [string]) { $report=$body|ConvertFrom-Json -Depth 20; $raw=$body } else { $report=$body; $raw=$body|ConvertTo-Json -Depth 20 -Compress }

    $wid = $env:WorkspaceId; $wkey = $env:WorkspaceKey; $logType = "MDEToolkitHealth"
    if (-not $wid -or -not $wkey) { Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{StatusCode=[HttpStatusCode]::InternalServerError;Body="WorkspaceId/Key missing"}); return }

    $date = [DateTime]::UtcNow.ToString("r"); $len = [System.Text.Encoding]::UTF8.GetByteCount($raw)
    $str = "POST`n${len}`napplication/json`nx-ms-date:${date}`n/api/logs"
    $hmac = New-Object System.Security.Cryptography.HMACSHA256; $hmac.Key = [Convert]::FromBase64String($wkey)
    $sig = [Convert]::ToBase64String($hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($str)))
    $auth = "SharedKey ${wid}:${sig}"

    $resp = Invoke-WebRequest -Uri "https://${wid}.ods.opinsights.azure.com/api/logs?api-version=2016-04-01" `
        -Method Post -Headers @{Authorization=$auth;"Content-Type"="application/json";"Log-Type"=$logType;"x-ms-date"=$date;"time-generated-field"="generatedAtUtc"} -Body $raw -UseBasicParsing

    $hn = if ($report.hostname) {$report.hostname} else {"unknown"}
    Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{StatusCode=[HttpStatusCode]::OK;Headers=@{"Content-Type"="application/json"};Body=(@{success=$true;destination="loganalytics";logType="${logType}_CL";hostname=$hn}|ConvertTo-Json)})
} catch { Write-Error "Error: $_"; Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{StatusCode=[HttpStatusCode]::InternalServerError;Body="Error: $_"}) }
⚠️ Not Tested. Requires a Dataverse table with matching columns and the Function App's MI granted Dataverse System User.

Upserts into Dataverse for Power Platform dashboards and Power Automate flows.

Required: DataverseUrl, DataverseTableName

run.ps1 — Dataverse (Not Tested)
using namespace System.Net
param($Request, $TriggerMetadata)

try {
    $body = $Request.Body
    if (-not $body) { Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{StatusCode=[HttpStatusCode]::BadRequest;Body="Empty"}); return }
    if ($body -is [string]) { $report=$body|ConvertFrom-Json -Depth 20 } else { $report=$body }

    function Coalesce { param($a,$b) if ($null -ne $a -and $a -ne '') {$a} else {$b} }
    function CoalesceBool { param($a,$b) if ($a) {[bool]$a} elseif ($b) {[bool]$b} else {$false} }
    function CoalesceInt { param($a,$b) if ($a) {[int]$a} elseif ($b) {[int]$b} else {0} }

    $dvUrl = $env:DataverseUrl; $tn = if ($env:DataverseTableName) {$env:DataverseTableName} else {"mde_healthreports"}
    if (-not $dvUrl) { Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{StatusCode=[HttpStatusCode]::InternalServerError;Body="DataverseUrl missing"}); return }

    $hn = if ($Request.Headers["X-Device-Hostname"]) {$Request.Headers["X-Device-Hostname"]} elseif ($report.hostname) {$report.hostname} else {"unknown"}
    $rid = if ($report.reportId) {$report.reportId} else {[Guid]::NewGuid().ToString()}

    $dvr = $dvUrl.TrimEnd('/')
    $tok = (Invoke-RestMethod -Uri "$($env:IDENTITY_ENDPOINT)?resource=${dvr}&api-version=2019-08-01" -Headers @{"X-IDENTITY-HEADER"=$env:IDENTITY_HEADER} -Method Get).access_token

    $ent = @{
        mde_name="${hn}_${rid}"; mde_hostname=$hn; mde_reportid=$rid
        mde_healthscore=CoalesceInt $report.healthScore $null
        mde_overallstatus=if($report.overallStatus){[string]$report.overallStatus}else{"Unknown"}
        mde_isonboarded=CoalesceBool $report.isOnboarded $null
        mde_generatedat=(Get-Date).ToUniversalTime().ToString("o")
        mde_reportjson=($report|ConvertTo-Json -Depth 10 -Compress)
    }

    Invoke-RestMethod -Uri "${dvr}/api/data/v9.2/${tn}(mde_name='$($ent.mde_name)')" -Method Patch `
        -Headers @{Authorization="Bearer $tok";"Content-Type"="application/json";"OData-MaxVersion"="4.0";"OData-Version"="4.0";"If-Match"="*";"Prefer"="return=representation"} `
        -Body ($ent|ConvertTo-Json -Depth 5 -Compress)

    Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{StatusCode=[HttpStatusCode]::OK;Headers=@{"Content-Type"="application/json"};Body=(@{success=$true;destination="dataverse";hostname=$hn}|ConvertTo-Json)})
} catch { Write-Error "Error: $_"; Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{StatusCode=[HttpStatusCode]::InternalServerError;Body="Error: $_"}) }

2.4 Data Destination Reference

The Function App upserts each report as a row with flat, typed columns for Power BI.

CategoryColumns
IdentityHostname, OsVersion, OrganizationId, DeviceGroup
Defender AVDefenderEnabled, RealTimeProtectionEnabled, TamperProtectionEnabled, SignatureAgeHours
MDEIsOnboarded, SenseIsRunning, MdeStatus
ASRAsrIsConfigured, AsrTotalRules, AsrBlockModeRules
FirewallFirewallDomainEnabled, FirewallPrivateEnabled, FirewallPublicEnabled
VBS / HVCIVbsEnabled, HvciRunning, CredentialGuardRunning
HealthHealthScore (0-100), OverallStatus, CriticalEventCount
Full ReportReportJson — complete snapshot for drill-down

Direct blob upload writes full JSON as {hostname}/{timestamp}.json. Useful for Data Factory or Logic Apps pipelines.

Required Role: Storage Blob Data Contributor on the container.

Coming Soon. Dataverse integration is planned for a future release.
Coming Soon. Sentinel / Log Analytics integration is planned for a future release.

2.5 Deploy

Azure CLI
cd FunctionApp
func azure functionapp publish func-mde-toolkit --powershell

2.6 Government Cloud

Set AzureEnvironment=Government in app settings. The script auto-detects core.usgovcloudapi.net.

3. Securing the Function App

The Function App endpoint is open to the internet by default. Use the layers below to restrict who can upload data.

3.1 Enable Entra ID Identity Provider

Configure the Function App to require a valid Entra ID bearer token on every request. This is the primary authentication layer — without a valid token, the request gets a 401.

Azure CLI — Enable Entra ID authentication
# Get the App Registration's client ID (from step 1.4)
APP_ID=$(az ad app list \
  --display-name "MDE Toolkit Function" \
  --query "[0].appId" -o tsv)

# Enable Entra ID authentication on the Function App
az webapp auth update \
  --name func-mde-toolkit \
  --resource-group rg-mde-toolkit \
  --enabled true \
  --action LoginWithAzureActiveDirectory \
  --aad-client-id $APP_ID \
  --aad-token-issuer-url "https://login.microsoftonline.com/YOUR-TENANT-ID/v2.0"
📸

Screenshot: Azure Portal → Function App → Authentication → Add Identity Provider → Microsoft Entra ID

How it works. When you enable the Identity Provider, the Azure App Service authentication middleware (formerly "EasyAuth") validates every incoming request's Authorization: Bearer header. The MDE Toolkit client acquires this token using the logged-on user's PRT via DefaultAzureCredential, scoped to api://mde-toolkit-func.

3.2 Conditional Access — Restrict by Device State

Use Entra ID Conditional Access to control which devices can acquire a token for the Function App. This lets you restrict uploads to only compliant, Entra-joined, or hybrid-joined machines.

Recommended Policy

SettingValue
Target resourceThe App Registration (MDE Toolkit Function)
UsersAll users (or a specific group)
Conditions → Device platformsWindows only
GrantRequire device to be marked as compliant
OR Require Hybrid Azure AD joined device
📸

Screenshot: Entra ID → Conditional Access → New Policy → Grant controls → Require compliant device

Effect: A non-compliant or non-joined device will fail to acquire a token, so the upload task exits gracefully with an auth error. Collection still succeeds (runs as SYSTEM, no token needed).

3.3 Network Restrictions (Optional)

For additional defense-in-depth, restrict network access to the Function App.

IP Allow List

In Function App → Networking → Access Restrictions, add the public IP ranges of your corporate network or VPN exit points.

📸

Screenshot: Networking → Access Restrictions

Private Endpoint

For zero-trust environments, deploy the Function App with a Private Endpoint so it's only reachable from your VNet. Endpoints connect via VPN or ExpressRoute.

📸

Screenshot: Networking → Private Endpoints

3.4 Security Checklist

LayerWhat It DoesRequired?
Identity Provider (Entra ID)Validates bearer token on every request — rejects anonymous calls✅ Yes
Conditional AccessRestricts token issuance to compliant / joined devices✅ Recommended
Managed Identity (Function → Storage)No storage keys — Function App uses MI to write to Table/Blob✅ Yes
IP Access RestrictionsOnly allows requests from known corporate IP rangesOptional
Private EndpointFunction App only reachable from VNet — no public IPOptional (zero-trust)
Function App KeysNot used — auth is handled by Identity ProviderN/A