Deploy and verify

Deploy the Management API and Portal to Azure, prove each piece with the verification script, then turn on endpoints in stages.

Before you begin

Azure is the only supported way to host the portal and API today. An on-premises install (Windows servers with Active Directory Certificate Services, with or without Entra ID) is coming soon.

  • Read the architecture and decide which Entra ID tenant and Azure subscription will host the service.
  • Have the API and portal release zips. The .hwlic license file comes after the deploy: the portal creates the Installation ID you request it with (Admin → License).
  • You need permission to create app registrations, an Azure SQL database and App Service web apps, and to assign app roles.

The deployment takes about an hour the first time. Deploy-HighwindFleet.ps1 runs the same steps for you and is safe to re-run.

Deploy to Azure

You will create two Entra app registrations, an Azure SQL database and two Linux App Service web apps. Budget about an hour the first time. Every command is PowerShell with the Azure CLI, and later steps reuse the variables set here. Pick your cloud and the setup block changes to match; the later steps need no editing.

Commands for

Prefer one command?

Deploy-HighwindFleet.ps1 runs steps 1 to 7 for you, in the same order and with the same settings. Run it from the folder that holds the two release zips, after az login. The prerequisites below still apply.

  • Safe to re-run. Each step checks what already exists and only creates or fixes what is missing, so it also brings an existing deployment up to date.
  • Run it with -WhatIf first. It runs the pre-flight checks and lists the changes without making any.
  • Problems are warnings, not errors. If the region cannot host your size, the script lists the regions that can and asks which to use. If the resource group already exists in a region that cannot, it asks for another resource group name. For anything else it asks whether to continue.
Preview, then deploy
az cloud set --name AzureCloud; az login --tenant "<your-tenant-id>"
$common = @{
    Cloud             = "AzureCloud"
az cloud set --name AzureUSGovernment; az login --tenant "<your-tenant-id>"
$common = @{
    Cloud             = "AzureUSGovernment"
    TenantId          = "<your-tenant-id>"
    OrganizationName  = "<your organization>"
    AuthorizedDomains = "<yourdomain.com>"           # device DNS domains allowed to enroll
    NameSuffix        = "<unique>"                   # names become highwind-fleet-api-<unique>, sql-highwind-fleet-<unique>, ...
}
.\Deploy-HighwindFleet.ps1 @common -WhatIf                 # pre-flight checks, then lists what would change
.\Deploy-HighwindFleet.ps1 @common                         # makes the changes and waits for both sites

# If the region cannot host the size, the script lists the regions that can and asks. To decide up front:
.\Deploy-HighwindFleet.ps1 @common -Location <region>         # use this region
.\Deploy-HighwindFleet.ps1 @common -FindRegion             # use the first region that works, without asking
.\Deploy-HighwindFleet.ps1 @common -ResourceGroup <name>    # use another resource group (the default one may already exist elsewhere)

Pick a size

Add -Size to $common (Size = "Medium"). It sets the App Service plan and the database together. The default is Small. Run .\Deploy-HighwindFleet.ps1 -ShowSizes to see this table in the console.

SizeDevicesPortal usersApp Service planSQL databasePick it for
Small (default)up to about 1,0001 to 5B1 x 1 (1 core, 1.75 GB)S1 (20 DTU)Pilot, lab, small organization
Medium1,000 to 25,000up to 25P2v3 x 1 (4 cores, 16 GB)S4 (200 DTU)One or a few offices, mid-size company. Devices check in every 2 minutes.
Large25,000 to 150,000up to 50P3v3 x 2 (8 cores, 32 GB each)General Purpose, 4 vCoresLarge organization. Devices check in every 5 minutes.
XLarge150,000 to 600,00050 or moreP3v3 x 3 (8 cores, 32 GB each)Hyperscale, 8 vCoresEnterprise fleet. Devices check in every 10 minutes.

How to pick

  • Start from the number of devices. When in doubt, take the next size up.
  • Go up one size if many people use the portal at once.
  • Medium, Large and XLarge lengthen how often devices check in. Large and XLarge also raise the API request limit, so the server is not overrun.
  • After a week, check the database CPU or DTU percentage in Azure and keep it under about 70 percent.
  • To change size later, run the script again with a different -Size. It resizes the plan and database.
  • -PlanSku, -PlanInstances and -DatabaseSku override one part.

The sizes come from measured database timings and a capacity model, not a full load test in Azure. See Scale and sizing for the numbers and assumptions.

Pre-flight checks

Before it creates anything, the script checks that:

  • the region exists in your cloud;
  • the App Service, SQL and Key Vault providers are registered and offer the service there;
  • the plan and database tiers exist in that region;
  • the SQL server, web app and Key Vault names are free;
  • the Go sqlcmd is installed (it offers to install it with winget).

Problems show as warnings and nothing is created.

  • Region cannot host the size: the script lists the regions that can and asks you to pick one.
  • Resource group name taken in another region: it asks for a different name.
  • Anything else: it asks whether to continue.
  • -WhatIf: it ends by asking whether to build for real.
  • Quota: not checked; a quota error shows when the plan is created.

-SkipPreflight turns the checks off.

Useful options

OptionWhat it does
-SizeSmall, Medium, Large or XLarge.
-LocationRegion to deploy to.
-FindRegionIf the region cannot host the size, use the first one that can, without asking.
-ResourceGroupUse another resource group.
-ApiRegistrationName, -PortalRegistrationNameEach deployment gets its own Entra registrations, named Highwind Fleet Management (suffix) and Highwind Management Portal (suffix). Pass these only to keep using registrations from an earlier run.
-AdminUpnExtra users to make Highwind.Admin. You are added automatically.
-SqlServerName, -ApiAppName, -PortalAppName, -KeyVaultNameUse existing names.
-SkipCodeDeployInfrastructure and settings only.
-RotatePortalSecretIssue a new portal client secret.
-AutoApproveAgents, -RequireEnrollment, -AllowModelEgressFleet policy for step 5.
-DeviceCaKeyProtection Software, -AllowGroupApprovalRule, -SkipDeviceIdentityDevice identity options for step 7. Software keeps a Standard vault.

Run Get-Help .\Deploy-HighwindFleet.ps1 -Detailed for the full list. When it finishes, go to Verify the deployment. Read on for the manual steps, which are also what the script does.

Before you start

  • Azure CLI 2.60 or later (winget install Microsoft.AzureCLI), signed in with az login --tenant <tenant-id>.
  • sqlcmd, Go edition: winget install sqlcmd. Its -G signs in with your az login account. Check with sqlcmd --version, which should print sqlcmd: v1.x. If that command errors, an older ODBC sqlcmd (installed with SQL Server tools) comes first on your PATH. Its -G means Integrated authentication and fails with “Could not discover a user realm”. The ODBC one is on the machine PATH, which Windows searches before the user PATH where winget puts the Go one. The setup block below adds an alias that fixes this for the session.
  • Azure rights: Contributor on the subscription or resource group.
  • Entra rights: Application Administrator to create registrations; Privileged Role Administrator or Global Administrator to grant admin consent and, in step 7, to give the API Microsoft Graph Device.Read.All.
  • The release packages: Highwind-Management-Api (Linux build, includes fleet-schema.sql) and Highwind-Management-Portal, on GitHub Releases (the release with Fleet in its name). The script downloads the newest one for you if the zips are not in -ReleaseFolder, and checks their SHA-256. Use -NoDownload to turn that off. The Linux API zip leaves out the other platforms’ native libraries, so the upload is a third of the size and does not time out.
Set once, reused by every step
# Azure commercial
az cloud set --name AzureCloud                       # sign in again with az login after switching clouds
$loginHost = "https://login.microsoftonline.com"
$graphHost = "https://graph.microsoft.com"
$sqlSuffix = "database.windows.net"
$webSuffix = "azurewebsites.net"
$location  = "eastus2"
# Azure Government (GCC High / DoD)
az cloud set --name AzureUSGovernment                # sign in again with az login after switching clouds
$loginHost = "https://login.microsoftonline.us"
$graphHost = "https://graph.microsoft.us"
$sqlSuffix = "database.usgovcloudapi.net"
$webSuffix = "azurewebsites.us"
$location  = "usgovvirginia"                         # or usgovarizona, usgovtexas


# Use the Go sqlcmd even if an older ODBC sqlcmd comes first on PATH (aliases beat PATH)
if (Test-Path "$env:ProgramFiles\SqlCmd\sqlcmd.exe") { Set-Alias sqlcmd "$env:ProgramFiles\SqlCmd\sqlcmd.exe" }
sqlcmd --version                                     # sqlcmd: v1.x

$tenantId  = "<your-tenant-id>"
$rg        = "rg-highwind-fleet"
$sqlServer = "sql-highwind-fleet-<unique>"      # globally unique, lowercase
$plan      = "asp-highwind-fleet"
$apiApp    = "highwind-fleet-api-<unique>"      # becomes https://$apiApp.$webSuffix
$portalApp = "highwind-fleet-portal-<unique>"
$version   = "3.7.0"

1. Register the API in Entra ID

Create the registration, its identifier and the three app roles
$apiAppId = az ad app create --display-name "Highwind Fleet Management" --sign-in-audience AzureADMyOrg --query appId -o tsv
$apiAudience = "api://$apiAppId"            # Application ID URI; allowed by every tenant policy
az ad app update --id $apiAppId --identifier-uris $apiAudience
az ad sp create --id $apiAppId

$roles = "Highwind.Agent", "Highwind.Operator", "Highwind.Admin" | ForEach-Object {
    @{ value = $_; displayName = $_; description = $_; id = [guid]::NewGuid(); isEnabled = $true
       allowedMemberTypes = @("User", "Application") } }
ConvertTo-Json @($roles) | Set-Content roles.json
az ad app update --id $apiAppId --app-roles "@roles.json"
  • Use these exact role values; any other spelling gives every caller a 403.
  • Application ID URI. api://{app-id} is used because the default tenant policy refuses URIs that do not contain a verified domain, the tenant ID or the app ID (“All newly added URIs must contain a tenant verified domain…”). A verified-domain URI such as api://contoso.com/highwind-fleet also works; if you use one, set $apiAudience to it. Whatever you choose, the same value goes in the API’s Audience, the portal’s scope and every endpoint’s FleetManagementAudience.
  • Portal: Entra admin center → App registrations → New registration (single tenant, no redirect URI). Then Expose an API → Application ID URI → Add (it proposes api://{app-id}; accept it), and App roles → Create app role three times, with Allowed member types set to Both.

The remaining settings can be made in the portal or from the command line. The commands use Microsoft Graph through az rest, because the Azure CLI has no dedicated command for them. Define this helper once in the same session:

Graph helper (JSON goes through a file, which avoids quoting problems with az.cmd)
function Invoke-Graph($Method, $Path, $Body) {
    $file = New-TemporaryFile
    $Body | ConvertTo-Json -Depth 8 | Set-Content $file
    az rest --method $Method --uri "$graphHost/v1.0/$Path" --headers "Content-Type=application/json" --body "@$file"
    Remove-Item $file
}
$apiObjectId = az ad app show --id $apiAppId --query id -o tsv
$apiSpId     = az ad sp show --id $apiAppId --query id -o tsv

1a. Delegated scope Fleet.Access

The portal uses this scope to call the API as the signed-in person.

Azure CLI
Invoke-Graph PATCH "applications/$apiObjectId" @{ api = @{ oauth2PermissionScopes = @(@{
    id = [guid]::NewGuid(); value = "Fleet.Access"; type = "User"; isEnabled = $true
    adminConsentDisplayName = "Access Highwind Fleet"; adminConsentDescription = "Use the Highwind Management Portal as the signed-in user."
    userConsentDisplayName  = "Access Highwind Fleet"; userConsentDescription  = "Use the Highwind Management Portal as you." }) } }
az ad app show --id $apiAppId --query "api.oauth2PermissionScopes[].value"      # ["Fleet.Access"]

Portal: on the registration, Expose an API → Add a scope. Scope name Fleet.Access, Who can consent: Admins and users, fill in the display names, then Enabled.

1b. Require assignment

Without this, anyone in the tenant can get a token for the API. With no role, they still receive a 403.

Azure CLI
az ad sp update --id $apiAppId --set appRoleAssignmentRequired=true

Portal: Enterprise applications → Highwind Fleet Management → Properties → Assignment required? → Yes → Save.

1c. Give people Highwind.Operator or Highwind.Admin

Give yourself Highwind.Admin now, because the verification script needs it. Assigning to groups needs Microsoft Entra ID P1 or P2; on other tenants, assign users individually.

Azure CLI (repeat per person or group)
$roleId    = az ad sp show --id $apiAppId --query "appRoles[?value=='Highwind.Admin'].id" -o tsv     # or Highwind.Operator
$principal = az ad signed-in-user show --query id -o tsv
#  another user:  az ad user show --id alice@contoso.com --query id -o tsv
#  a group:       az ad group show --group "Highwind Fleet Admins" --query id -o tsv
Invoke-Graph POST "servicePrincipals/$apiSpId/appRoleAssignedTo" @{ principalId = $principal; resourceId = $apiSpId; appRoleId = $roleId }

az rest --uri "$graphHost/v1.0/servicePrincipals/$apiSpId/appRoleAssignedTo" --query "value[].principalDisplayName"

Portal: Enterprise applications → Highwind Fleet Management → Users and groups → Add user/group, pick the person or group, Select a role, then Assign. Role changes appear in new tokens only, so sign out and in again (or run az login again).

1d. Endpoints get Highwind.Agent

The identity your endpoints authenticate as is given Highwind.Agent as an application permission, with admin consent.

Verify the deployment, step 1, shows the commands with a test identity, and Turn on endpoints gives each kind of device an identity. Portal: on that identity’s registration, API permissions → Add a permission → My APIs → Highwind Fleet Management → Application permissions → Highwind.Agent, then Grant admin consent.

Entra issues v1 access tokens for this registration by default. Their issuer is https://sts.windows.net/{tenant-id}/ in the commercial cloud and their audience is the Application ID URI (api://{app-id}). If you set requestedAccessTokenVersion to 2 in the manifest, the issuer becomes the v2 issuer ({login-host}/{tenant-id}/v2.0) and the audience becomes the bare app ID GUID. Step 5 reads both issuers from your cloud’s metadata and configures the API to accept both, so either setting works in any cloud.

2. Set up Azure SQL

The API stores devices, requests, audit records and App Control policies in one Azure SQL database. The server uses Microsoft Entra authentication only: no SQL logins or passwords exist. You administer it through an Entra group, and the API connects with its managed identity.

2a. Resource group and SQL administrators group

The group becomes the server's Entra admin. Add a second admin as a break-glass account.
az group create --name $rg --location $location

$sqlAdmins = az ad group create --display-name "Highwind Fleet SQL Admins" `
    --mail-nickname highwind-fleet-sql-admins --query id -o tsv
az ad group member add --group $sqlAdmins --member-id (az ad signed-in-user show --query id -o tsv)

2b. Logical server with Entra-only authentication

Azure CLI
az sql server create --resource-group $rg --name $sqlServer --location $location `
    --enable-ad-only-auth `
    --external-admin-principal-type Group `
    --external-admin-name "Highwind Fleet SQL Admins" `
    --external-admin-sid $sqlAdmins `
    --minimal-tls-version 1.2

Portal alternative: Create SQL Database server → Authentication method Use Microsoft Entra-only authentication → Set admin → the group above. On the Networking tab, set the minimum TLS version to 1.2.

2c. The database

Pick one
# Pilot / small fleet - Standard S0 (10 DTU)
az sql db create --resource-group $rg --server $sqlServer --name HighwindFleet --service-objective S0

# Production - General Purpose, 2 vCores, zone redundant
az sql db create --resource-group $rg --server $sqlServer --name HighwindFleet `
    --edition GeneralPurpose --family Gen5 --capacity 2 --zone-redundant true
  • Scaling: you can scale later with az sql db update --service-objective without redeploying anything.
  • Serverless: avoid serverless with auto-pause. After an idle period the first heartbeat wakes the database, and /health/ready and early requests time out while it resumes.
  • Protection: transparent data encryption and point-in-time backups (7 days by default) are on automatically. Raise retention with az sql db str-policy set -g $rg -s $sqlServer -n HighwindFleet --retention-days 35 if your policy requires it.

2d. Network access

OptionWhenHow
Public endpoint + firewallPilot, evaluationAllow Azure services, plus a rule for the workstation that runs sqlcmd (commands below). Access still requires an Entra identity that has a user in the database.
Private endpointProductionCreate a private endpoint for the server in a VNet, integrate the API web app with that VNet (Networking → VNet integration), link the privatelink.{sql-suffix} DNS zone, then disable public access: az sql server update -g $rg -n $sqlServer --enable-public-network false. Run sqlcmd from a machine inside the VNet.
Pilot firewall rules
# 0.0.0.0 is the special "Allow Azure services and resources to access this server" rule
az sql server firewall-rule create -g $rg -s $sqlServer -n AllowAzureServices --start-ip-address 0.0.0.0 --end-ip-address 0.0.0.0

$myIp = Invoke-RestMethod https://api.ipify.org
az sql server firewall-rule create -g $rg -s $sqlServer -n AdminWorkstation --start-ip-address $myIp --end-ip-address $myIp

2e. Check you can connect

Uses your az login token. Expect HighwindFleet and your UPN.
sqlcmd -S "$sqlServer.$sqlSuffix" -d HighwindFleet -G -Q "SELECT DB_NAME(), SUSER_SNAME()"

Client with IP address ... is not allowed means the firewall rule is missing or your IP changed. Login failed for user '<token-identified principal>' means you are not in the SQL admins group, or signed in to the wrong tenant.

3. Apply the schema

The API does not create its own schema (ApplyMigrationsAtStartup is off), so a privileged account applies it once per release. The script is idempotent: re-running it, or running a newer release's script over an older database, is safe.

From the folder holding the release zip
Expand-Archive "Highwind-Management-Api-$version-linux-x64.zip" -DestinationPath api -Force
sqlcmd -S "$sqlServer.$sqlSuffix" -d HighwindFleet -G -i api\fleet-schema.sql

# Verify: one row per migration, the last named ..._AddAppControlPolicyRepository
sqlcmd -S "$sqlServer.$sqlSuffix" -d HighwindFleet -G -Q "SELECT MigrationId FROM __EFMigrationsHistory ORDER BY MigrationId"

4. Create the API web app and grant it database access

Linux App Service with a system-assigned managed identity
az appservice plan create -g $rg -n $plan --location $location --is-linux --sku B1    # P0v3 or higher for production
az webapp create -g $rg -p $plan -n $apiApp --runtime "DOTNETCORE:10.0" --assign-identity "[system]"
az webapp update -g $rg -n $apiApp --https-only true
az webapp config set -g $rg -n $apiApp --always-on true --min-tls-version 1.2 --ftps-state Disabled

In the portal, open the web app and go to Monitoring → Health check. Enable it with path /health/live.

Now create a database user for the web app’s identity. The user name is the web app name, and the API needs read, write and execute rights only, not db_owner:

Run as a member of Highwind Fleet SQL Admins
sqlcmd -S "$sqlServer.$sqlSuffix" -d HighwindFleet -G -Q @"
CREATE USER [$apiApp] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER [$apiApp];
ALTER ROLE db_datawriter ADD MEMBER [$apiApp];
GRANT EXECUTE TO [$apiApp];
"@

Run this as a person. If an automation service principal runs CREATE USER ... FROM EXTERNAL PROVIDER, the SQL server’s own identity also needs the Entra Directory Readers role.

5. Configure and deploy the API

App settings (App Service uses __ for nested keys)
# The v1 and v2 issuers for your tenant, read from your cloud's own metadata
$v1Issuer = (Invoke-RestMethod "$loginHost/$tenantId/.well-known/openid-configuration").issuer
$v2Issuer = (Invoke-RestMethod "$loginHost/$tenantId/v2.0/.well-known/openid-configuration").issuer
$v1Issuer; $v2Issuer     # https://sts.windows.net/{tid}/ and https://login.microsoftonline.comhttps://login.microsoftonline.us/{tid}/v2.0

$settings = @(
    "ConnectionStrings__FleetManagement=Server=tcp:$sqlServer.$sqlSuffix,1433;Database=HighwindFleet;Authentication=Active Directory Default;Encrypt=True;"
    "Fleet__Database__Provider=SqlServer"
    "Fleet__Database__ApplyMigrationsAtStartup=false"
    "Fleet__Authentication__Authority=$loginHost/$tenantId/v2.0"
    "Fleet__Authentication__Audience=$apiAudience"
    "Fleet__Authentication__ValidAudiences__0=$apiAudience"
    "Fleet__Authentication__ValidAudiences__1=$apiAppId"
    "Fleet__Authentication__ValidIssuers__0=$v2Issuer"
    "Fleet__Authentication__ValidIssuers__1=$v1Issuer"
    "Fleet__Authentication__RequireHttpsMetadata=true"
    "Fleet__Seed__Tenants__0__TenantId=$tenantId"
    "Fleet__Seed__Tenants__0__DisplayName=Contoso"
    "Fleet__Seed__Tenants__0__Enabled=true"
    "Fleet__Seed__Tenants__0__AutoApproveAgents=false"
    "Fleet__Seed__Tenants__0__RequireEnrollment=false"
    "Fleet__Seed__Tenants__0__EnforceDomainAllowlist=true"
    "Fleet__Seed__Tenants__0__AuthorizedDomains__0=contoso.com"
    "Fleet__Seed__Tenants__0__AllowModelEgress=false"
)
az webapp config appsettings set -g $rg -n $apiApp --settings $settings --output none
  • Both issuers and both audiences are listed on purpose, so v1 and v2 tokens are accepted (see the note in step 1). Both belong to your tenant only. If you leave ValidIssuers empty, only the v2 issuer is accepted, and the default v1 tokens fail with 401.
  • The seed is the only way to create a tenant, and it is create-only. Changing a seed value after the first start changes nothing. Decide approval, enrollment, domain allowlist and model egress before you deploy. Add further domains as AuthorizedDomains__1, __2 and so on. To correct a tenant after it has been seeded, use the SQL below.
  • No secret appears in any setting. The connection string authenticates with the managed identity.
Deploy, then verify in this order
az webapp deploy -g $rg -n $apiApp --src-path "Highwind-Management-Api-$version-linux-x64.zip" --type zip --timeout 900000
# A "Status Code: 502" here is usually the front end timing out on the upload while Kudu carries on.
# Before retrying, wait a minute and try /health/live below; a 404 means the default placeholder site is still being served.
$api = "https://$apiApp.$webSuffix"

Invoke-RestMethod "$api/health/live"                                                 # ok
curl.exe -s -o NUL -w "%{http_code}`n" "$api/health/ready"                            # MUST be 401

# Startup log: expect one "Onboarding tenant" line showing your tenant ID, display name and domain count.
# (az webapp log tail replays only the last few lines, so search the downloaded logs instead.)
az webapp log download -g $rg -n $apiApp --log-file "$env:TEMP\api-logs.zip"
Expand-Archive "$env:TEMP\api-logs.zip" "$env:TEMP\api-logs" -Force
Get-ChildItem "$env:TEMP\api-logs" -Recurse -File | Select-String "Onboard" | ForEach-Object Line
Only if a seeded value was wrong: correct it in the database, then restart
# The seed never overwrites an existing tenant, so edit the rows directly.
sqlcmd -S "$sqlServer.$sqlSuffix" -d HighwindFleet -G -Q "
UPDATE Tenants SET DisplayName = N'Your Organization', ConcurrencyStamp = NEWID() WHERE TenantId = '$tenantId';
DELETE AuthorizedDomains WHERE TenantId = '$tenantId' AND NormalizedDomain = N'contoso.com';
INSERT AuthorizedDomains (Id, TenantId, NormalizedDomain, AddedAtUtc) VALUES (NEWID(), '$tenantId', N'yourdomain.com', SYSDATETIMEOFFSET());
SELECT t.DisplayName, d.NormalizedDomain FROM Tenants t LEFT JOIN AuthorizedDomains d ON d.TenantId = t.TenantId;"
az webapp restart -g $rg -n $apiApp
# Write domains in lowercase with no trailing dot; that is the form the API compares against.
# Also correct the Fleet__Seed__* app settings, so a rebuilt database is seeded with the right values.
If /health/ready returns 200 without a token, stop and fix authentication before anything else. The readiness check with a real token comes in Verify the deployment.

6. Register, configure and deploy the portal

Second, confidential app registration and its web app
$portalAppId = az ad app create --display-name "Highwind Management Portal" --sign-in-audience AzureADMyOrg `
    --web-redirect-uris "https://$portalApp.$webSuffix/signin-oidc" "https://$portalApp.$webSuffix/signout-callback-oidc" `
    --enable-id-token-issuance false --query appId -o tsv
az ad sp create --id $portalAppId --output none
$portalSecret = az ad app credential reset --id $portalAppId --display-name portal --years 1 --query password -o tsv

az webapp create -g $rg -p $plan -n $portalApp --runtime "DOTNETCORE:10.0" --assign-identity "[system]"
az webapp update -g $rg -n $portalApp --https-only true
6a. Let the portal call the API (delegated Fleet.Access, admin consent)
$scopeId = az ad app show --id $apiAppId --query "api.oauth2PermissionScopes[?value=='Fleet.Access'].id" -o tsv
az ad app permission add   --id $portalAppId --api $apiAppId --api-permissions "$scopeId=Scope"
az ad app permission grant --id $portalAppId --api $apiAppId --scope Fleet.Access
# Sign-in scopes, consented for everyone so users are not prompted (and are not blocked where user consent is off).
$graphAppId = "00000003-0000-0000-c000-000000000000"
az ad app permission grant --id $portalAppId --api $graphAppId --scope "openid profile offline_access"
az ad app permission list-grants --id $portalAppId --query "[].scope" -o tsv    # Fleet.Access, then openid profile offline_access

In the Entra admin center instead: on the Highwind Management Portal registration, API permissions → Add a permission → APIs my organization uses → Highwind Fleet Management → Delegated → Fleet.Access, then Grant admin consent. Add https://{portal-host}/signout-callback-oidc as a second Web redirect URI under Authentication.

6b. Keep the client secret in Key Vault
$vault = "kv-highwind-<unique>"                    # 3-24 characters, globally unique
az keyvault create -g $rg -n $vault -l $location --enable-rbac-authorization true --output none
$kvId     = az keyvault show -n $vault --query id -o tsv
$vaultUri = az keyvault show -n $vault --query properties.vaultUri -o tsv       # ends in /; the cloud's vault suffix is included

# You need to write the secret; the portal only needs to read it.
$me = az ad signed-in-user show --query id -o tsv
az role assignment create --assignee-object-id $me --assignee-principal-type User --role "Key Vault Secrets Officer" --scope $kvId --output none
$portalMi = az webapp identity show -g $rg -n $portalApp --query principalId -o tsv
az role assignment create --assignee-object-id $portalMi --assignee-principal-type ServicePrincipal --role "Key Vault Secrets User" --scope $kvId --output none

Start-Sleep 60                                         # role assignments take a minute to apply
az keyvault secret set --vault-name $vault -n portal-client-secret --value $portalSecret --output none
Remove-Variable portalSecret
6c. Portal settings
# Written to a JSON file because cmd.exe (behind az.cmd) breaks on the parentheses in the Key Vault reference.
$portalSettings = [ordered]@{
    Portal__ApiBaseAddress     = "https://$apiApp.$webSuffix/"
    Portal__ApiScope           = "$apiAudience/Fleet.Access"
    Portal__Authority          = "$loginHost/$tenantId/v2.0"
    Portal__ClientId           = $portalAppId
    Portal__ClientSecret       = "@Microsoft.KeyVault(SecretUri=${vaultUri}secrets/portal-client-secret/)"
    Portal__BehindReverseProxy = "true"     # App Service terminates TLS, so the redirect URI is built with https
}
$portalSettings | ConvertTo-Json | Set-Content "$env:TEMP\portal-settings.json" -Encoding ascii
az webapp config appsettings set -g $rg -n $portalApp --settings "@$env:TEMP\portal-settings.json" --output none
az webapp config appsettings list -g $rg -n $portalApp --query "[].name" -o tsv   # six Portal__ names
6d. Deploy and verify
az webapp deploy -g $rg -n $portalApp --src-path "Highwind-Management-Portal-$version.zip" --type zip --timeout 900000
Invoke-RestMethod "https://$portalApp.$webSuffix/health/live"                                   # ok
curl.exe -s -o NUL -w "%{redirect_url}`n" "https://$portalApp.$webSuffix/"            # $loginHost/...

Sign in as someone with Highwind.Operator and confirm the dashboard loads. Then sign in as someone with no Highwind role and confirm they are refused. If they are not refused, the roles were assigned on the wrong registration, or Assignment required is off.

7. Device identity (zero-touch endpoints)

This lets Entra joined and hybrid joined devices sign in with nothing distributed to them (Turn on endpoints). The API checks each device against Entra once, then issues it a 30-day certificate from a device CA it creates itself.

You create one signing key; you do not build a CA, cut an issuer certificate or touch the devices. The private key never leaves Key Vault, and the API can sign with that key and nothing else.

Upgrading an existing deployment? Deploy the new API and portal zips first and re-run step 3 with the new fleet-schema.sql. It is idempotent and adds the device identity tables.

7a. Signing key (Key Vault Premium keeps it in an HSM, about USD 1 a month)
$vault = "kv-highwind-<unique>"                    # the vault from step 6b
$kvId  = az keyvault show -n $vault --query id -o tsv
az keyvault update -n $vault -g $rg --set properties.sku.name=premium --output none          # skip to keep Standard; then use --kty EC below
$me = az ad signed-in-user show --query id -o tsv
az role assignment create --assignee-object-id $me --assignee-principal-type User --role "Key Vault Crypto Officer" --scope $kvId --output none
Start-Sleep 60
az keyvault key create --vault-name $vault -n fleet-device-ca --kty EC-HSM --curve P-384 --ops sign verify --query key.kid -o tsv

# The API may sign with this one key, and nothing else in the vault.
$apiMi = az webapp identity show -g $rg -n $apiApp --query principalId -o tsv
az role assignment create --assignee-object-id $apiMi --assignee-principal-type ServicePrincipal --role "Key Vault Crypto User" --scope "$kvId/keys/fleet-device-ca" --output none
7b. Let the API read devices in Entra (Privileged Role Administrator or Global Administrator)
$graphSpId = az ad sp show --id 00000003-0000-0000-c000-000000000000 --query id -o tsv
# Device.Read.All, application permission. Add GroupMember.Read.All (98830695-27a2-44f7-8c18-0c3ebc9698f6) only if an approval rule names a group.
Invoke-Graph POST "servicePrincipals/$apiMi/appRoleAssignments" @{ principalId = $apiMi; resourceId = $graphSpId; appRoleId = "7438b122-aefc-4978-80ed-43db9fcc7715" }
7c. API settings (saving restarts the API)
$vaultUri = az keyvault show -n $vault --query properties.vaultUri -o tsv
[ordered]@{
    Fleet__DeviceIdentity__Enabled       = "true"
    Fleet__DeviceIdentity__Audience      = "https://$apiApp.$webSuffix/"        # what devices sign for; same as FleetManagementServiceUri
    Fleet__DeviceIdentity__KeyVaultKeyId = "${vaultUri}keys/fleet-device-ca"    # versionless
    Fleet__DeviceIdentity__GraphEndpoint = $graphHost
} | ConvertTo-Json | Set-Content "$env:TEMP\device-identity.json" -Encoding ascii
az webapp config appsettings set -g $rg -n $apiApp --settings "@$env:TEMP\device-identity.json" --output none
Invoke-RestMethod "https://$apiApp.$webSuffix/health/live"                       # ok, after the restart

7d. In the portal, as a Highwind.Admin, open Device identity:

  1. Press Create and test now. The CA appears with a thumbprint, which proves the key and both role assignments work. If it fails with HW-IDENT-001, the API cannot use the key: check 7a and wait a few minutes for the role assignment.
  2. Tick Allow devices in this tenant to attest. A tenant created before this release starts with it off. New tenants get it from Fleet__Seed__Tenants__0__DeviceAttestationEnabled.
  3. Choose approval. The default is Wait for an administrator. For a wide rollout, choose automatic approval and keep compliant and managed ticked, so a device that is not enrolled in Intune still waits for you.

The other settings have sensible defaults: CertificateLifetimeDays (30, up to 397), MaxRekeysPer24Hours (3), DirectorySyncIntervalMinutes (60; a device deleted or disabled in Entra is suspended at the next sync) and ManagedIdentityClientId (for a user-assigned identity), all under Fleet__DeviceIdentity__. A managed identity reads only its own tenant, so attestation works for the tenant the API runs in.

Verify the deployment

Commands for

The verification script checks the API end to end with real Entra tokens before any endpoint is involved. It plays the part of a device, using the same kind of app-only token a real agent uses, and prints PASS, FAIL or SKIP for each check.

Step 1: Create a test agent identity

An app registration that holds the Highwind.Agent application permission, standing in for an endpoint. Delete it when you are done.

Azure CLI
$agentAppId = az ad app create --display-name "Highwind Fleet Test Agent" --sign-in-audience AzureADMyOrg --query appId -o tsv
az ad sp create --id $agentAppId
$agentRole = az ad app show --id $apiAppId --query "appRoles[?value=='Highwind.Agent'].id" -o tsv
az ad app permission add --id $agentAppId --api $apiAppId --api-permissions "$agentRole=Role"
az ad app permission admin-consent --id $agentAppId
$agentSecret = az ad app credential reset --id $agentAppId --display-name verify --end-date (Get-Date).AddDays(2).ToString("yyyy-MM-dd") --query password -o tsv

Portal: App registrations → New registration “Highwind Fleet Test Agent”; API permissions → Add a permission → My APIs → Highwind Fleet Management → Application permissions → Highwind.Agent → Grant admin consent; Certificates & secrets → New client secret.

Step 2: Let the Azure CLI request tokens for you

The script gets your operator token with az account get-access-token, so the API has to trust the Azure CLI (04b07795-8ddb-461a-bbee-02f9e1bf7b46) as a client for Fleet.Access.

Azure CLI (uses Invoke-Graph and $apiObjectId from step 1 of the deployment)
$api = az ad app show --id $apiAppId --query api -o json | ConvertFrom-Json
$scopeId = ($api.oauth2PermissionScopes | Where-Object value -eq "Fleet.Access").id
$api.preAuthorizedApplications = @(@{ appId = "04b07795-8ddb-461a-bbee-02f9e1bf7b46"; delegatedPermissionIds = @($scopeId) })
Invoke-Graph PATCH "applications/$apiObjectId" @{ api = $api }

Portal: on the Highwind Fleet Management registration, Expose an API → Add a client application, enter 04b07795-8ddb-461a-bbee-02f9e1bf7b46 and tick Fleet.Access.

Then sign in as a person who holds Highwind.Admin:

PowerShell 7
az login --tenant $tenantId

Remove the Azure CLI client again after testing if you do not want operators to call the API from the command line.

Step 3: Run the script

Download Test-HighwindCentralSite.ps1

PowerShell 7 (pwsh)
Unblock-File .\Test-HighwindCentralSite.ps1
.\Test-HighwindCentralSite.ps1 -ApiBaseUri "https://$apiApp.$webSuffix" -TenantId $tenantId -Audience $apiAudience -AuthorityHost $loginHost `
    -AgentClientId $agentAppId -AgentClientSecret (ConvertTo-SecureString $agentSecret -AsPlainText -Force) `
    -DeviceDomain contoso.com
ParameterUse it when
-DeviceDomainAlways, with one of your seeded AuthorizedDomains
-ModelEgressAllowedThe tenant was seeded with AllowModelEgress = true (otherwise the script expects a refusal)
-OperatorToken / -AdminTokenYou want to prove that an operator is refused admin actions. Pass a token from someone with Highwind.Operator only.
-OtherTenantOperatorTokenThe API serves a second tenant and you want the cross-tenant isolation check
-IncludeAppControlUploadYou want to test policy upload. The repository is append-only, so the test policies stay.
-KeepDeviceYou want to leave the test device active instead of revoking it
-AuthorityHostSovereign clouds, e.g. https://login.microsoftonline.us

The script adapts to the tenant settings. If devices need approval, it approves the test device as admin. If enrollment is required, it mints and redeems a single-use secret. At the end it revokes the test device, which stays listed for audit.

Expected output (tenant with approval required, one admin sign-in)
[1] Tokens, authentication and readiness
  PASS  Liveness is anonymous (200)
  PASS  Readiness without a token is refused (401)
        operator  ver 1.0  aud api://<api-app-id>  roles Highwind.Admin, Highwind.Operator
                  iss https://sts.windows.net/<tenant-id>/
        agent     ver 1.0  aud api://<api-app-id>  roles Highwind.Agent
                  iss https://sts.windows.net/<tenant-id>/
  PASS  Operator token carries Highwind.Operator or Highwind.Admin
  PASS  Agent token carries Highwind.Agent
  PASS  Readiness with the operator token (200)

[2] Register a device and send heartbeats
  PASS  Register HW-VERIFY-48213 (200)
        tenant does not auto-approve - approving as admin
  PASS  Admin approves the device (200, Active)
  PASS  Heartbeat sequence 1 (200)
  PASS  Replayed heartbeat sequence is refused (409)
  PASS  Operator sees the device in the inventory

[3] Operator request -> agent poll -> agent result
  PASS  Submit GetAgentHealth (202)
  PASS  Agent poll returns the message (200)
  PASS  Agent submits the result (200)
  PASS  Request shows Succeeded with the result
  PASS  Operation the device did not advertise is refused (403)

[4] Distributed query
  PASS  Submit query (202)
  PASS  Read query with results (200)

[5] Model egress grant (tenant policy AllowModelEgress)
  PASS  Egress not allowed: refused (403 HW-AI-001)
  PASS  Cancel the query (200, Cancelled)

[6] Tenant isolation
  PASS  An unknown device is a 404
  SKIP  Another tenant cannot see this device  (no -OtherTenantOperatorToken)

[7] Administrator operations and device lifecycle
  SKIP  Operator is refused admin operations  (the operator token also has Highwind.Admin; ...)
  PASS  Admin lists certificate authorities (200)
  PASS  Admin lists enrollment tokens (200)
  PASS  Disable without a reason is refused (400)
  PASS  Admin disables the device (200)
  PASS  Admin reinstates the device (200)

[8] App Control policy repository
  PASS  List policies (200)
  SKIP  Policy upload  (append-only; add -IncludeAppControlUpload to test it)

[9] Clean up
  PASS  Revoke HW-VERIFY-48213 (200)

All checks passed (3 skipped).

Step 4: Check the portal and clean up

  • In the portal, Devices lists HW-VERIFY-… as Revoked, and the dashboard counts match.
  • Delete the test agent: az ad app delete --id $agentAppId.
  • Remove the AdminWorkstation SQL firewall rule if you no longer need it: az sql server firewall-rule delete -g $rg -s $sqlServer -n AdminWorkstation.

Turn on endpoints

Three things have to be true on a device: the service is installed, the service has an identity it can sign in with, and policy tells it where the API is. Missing any one of them and the device never appears in the portal.

1. Install the service

msiexec /i Highwind-{version}.msi /qn INSTALLFLEETAGENT=1. Upgrades keep it registered; uninstall removes it. The service is HighwindFleetAgent and runs as LocalSystem.

2. Give the agent an identity

For Entra joined and hybrid joined devices, use device certificates: there is nothing to do on the device. The other options are for devices that are not joined to Entra.

In EntraId mode the agent asks Entra for an app-only token for the API’s audience, using the standard Azure SDK credential chain as LocalSystem.

That chain does not use the device’s Entra join: an Entra-joined or hybrid-joined laptop has nothing to sign in with until you give it one, and the agent logs Could not acquire a token for the management service. Whatever it signs in as must hold the Highwind.Agent application role.

DevicesIdentitySet up with
Entra joined and hybrid joined devices (recommended)A device certificate issued by this service, per device, key in the TPM. Nothing to distributeDevice certificates below, plus step 7 once
Other workstations and on-premises serversOne agent app registration for the estate, signing in with a certificateOption A below
Azure VMs and Azure Arc–enabled serversThe machine’s managed identity; nothing to distributeOption B below
AD-only, workgroup or disconnected devicesA device certificate from your CA (mutual TLS, no Entra token)Option C below

Recommended: device certificates (Entra joined and hybrid joined)

Set Fleet agent authentication mode to Device certificate in policy (step 3) and you are done. On its first run the agent:

  1. finds the device’s Entra certificate (MS-Organization-Access, created at join) and creates its own key in the TPM;
  2. signs a one-time challenge from the API with the Entra key. The API checks with Entra that the device exists, is enabled, is joined (not merely registered) and holds that key;
  3. receives its own 30-day certificate and signs every request with the TPM key. It renews at half-life, and a device that was offline past expiry simply proves itself again.

The device then waits for approval in the portal, unless your approval rule lets it straight in.

  • Device page: shows how it proved itself, its join type, whether its key is in the TPM, and when its certificate expires.
  • Suspending or revoking: takes effect within a minute; deleting it in Entra suspends it at the next sync.
  • Hybrid joined devices: need the join to have completed: dsregcmd /status shows AzureAdJoined : YES.

Devices without a usable TPM fall back to a non-exportable software key. The portal shows it, and such devices are never approved automatically. Tick Require a hardware-protected key to refuse them instead.

Option A: agent app registration with a certificate

Run once from your admin workstation (uses $apiAppId from the deployment). It creates the registration, grants it Highwind.Agent, and writes a PEM file holding the certificate and its private key.

Admin workstation, once
$agentAppId = az ad app create --display-name "Highwind Fleet Agents" --sign-in-audience AzureADMyOrg --query appId -o tsv
az ad sp create --id $agentAppId --output none
$agentRole = az ad app show --id $apiAppId --query "appRoles[?value=='Highwind.Agent'].id" -o tsv
az ad app permission add --id $agentAppId --api $apiAppId --api-permissions "$agentRole=Role"
az ad app permission admin-consent --id $agentAppId
$pem = az ad app credential reset --id $agentAppId --create-cert --years 1 --query fileWithCertAndPrivateKey -o tsv
Copy-Item $pem .\highwind-agent.pem; Remove-Item $pem
"Tenant: $tenantId   Agent app: $agentAppId"     # values for the device step

On each device, elevated (in production, package this as an Intune Win32 app or a ConfigMgr script). The variables go into the service’s environment, so nothing else on the machine sees them and no reboot is needed; the key file is readable only by SYSTEM and Administrators.

Each device, elevated
$dir = "$env:ProgramData\Highwind\fleet"
New-Item $dir -ItemType Directory -Force | Out-Null
Copy-Item .\highwind-agent.pem "$dir\agent.pem" -Force
icacls "$dir\agent.pem" /inheritance:r /grant:r "*S-1-5-18:R" "*S-1-5-32-544:F" | Out-Null

$environment = @(
    "AZURE_TENANT_ID=<tenant-id>"
    "AZURE_CLIENT_ID=<agent-app-id>"
    "AZURE_CLIENT_CERTIFICATE_PATH=$dir\agent.pem"
    "AZURE_AUTHORITY_HOST=https://login.microsoftonline.us/"
)
Set-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Services\HighwindFleetAgent" -Name Environment -Type MultiString -Value $environment
Restart-Service HighwindFleetAgent

Every device shares this credential, so the token proves only that the caller is one of your agents; anyone with administrator rights on one device can read the key. That is why the API binds each registration to a device key and why RequireEnrollment exists: turn it on before a wide rollout. Renew the certificate before it expires (--years) and redistribute the file.

Option B: managed identity (Azure VMs, Azure Arc)

Turn on the system-assigned identity on the VM or Arc server, then grant it the role. No environment variables are needed: the agent finds the identity on its own.

Admin workstation (Invoke-Graph, $apiSpId from step 1 of the deployment)
$machineSpId = "<principal-id of the VM or Arc identity>"     # Identity blade, Object (principal) ID
$agentRole   = az ad app show --id $apiAppId --query "appRoles[?value=='Highwind.Agent'].id" -o tsv
Invoke-Graph POST "servicePrincipals/$apiSpId/appRoleAssignedTo" @{ principalId = $machineSpId; resourceId = $apiSpId; appRoleId = $agentRole }

Option C: device certificates (no Entra)

Enable Fleet:Authentication:ClientCertificate:Enabled on the API, register your issuing CA (root and intermediates) with POST /fleet/certificate-authorities, and set FleetAuthenticationMode = ClientCertificate plus FleetClientCertificateSubject on the device. Each device proves itself on every request, which makes this the strongest option where you already run a CA.

Hosting matters. The API reads the certificate from the TLS connection, or from a forwarded header (ForwardedHeaderName) only when the request comes from an address in TrustedProxies. On App Service, TLS ends at Azure’s front ends, which forward the certificate in X-ARR-ClientCert from addresses you cannot pin, so this mode has not been validated there.

Host the API where it terminates TLS itself (a VM, AKS or Container Apps with client certificates passed through) or behind a gateway with fixed addresses.

3. Push policy

Deploy to HKLM\SOFTWARE\Policies\Highwind with the ADMX (Group Policy or Intune). The fleet settings are under Highwind → Fleet Agent (Preview); the cloud setting is under Highwind → Identity and Authentication. Example values for a deployment named highwind-fleet-api-01:

ADMX settingValueRegistry value
Enable the fleet agentEnabledFleetDistributedAgentsEnabled = 1
Allow the agent to register this deviceEnabledFleetAgentRegistrationEnabled = 1
Allow the agent to send heartbeatsEnabledFleetAgentHeartbeatsEnabled = 1
Allow the agent to advertise its capabilitiesEnabled (to answer requests)FleetCapabilityDiscoveryEnabled = 1
Allow the agent to receive requestsEnabled (to answer requests)FleetBrokeredMessagingEnabled = 1
Fleet management service → Management service URLhttps://highwind-fleet-api-01.azurewebsites.nethttps://highwind-fleet-api-01.azurewebsites.us
Include https://; a bare host name is rejected
FleetManagementServiceUri
Fleet management service → Entra ID audienceEntra ID mode only; leave blank for device certificates. api://<api-app-id>, e.g. api://857e6dc9-f213-4455-be4a-d67632260c10. Not the URL, and without /.defaultFleetManagementAudience
Fleet management service → Expected tenantOptional: your tenant ID. A device joined to another tenant stopsFleetExpectedTenantId
Fleet agent authentication modeDevice certificate for Entra joined and hybrid joined devices; Entra ID (options A and B) or Client certificate (option C) otherwiseFleetAuthenticationMode
Fleet agent authentication mode → Require a hardware-protected (TPM) keyOptional. Ticked: a device with no usable TPM does not joinFleetRequireHardwareKey = 1
Accept client configuration from the management portalOptional. On when not configured: reports the portal sets (Admin › Client configuration) win over the values above. Disabled makes the device use local policy onlyFleetPortalConfigurationEnabled = 0 to ignore the portal
Report the last signed-in userOptional. On when not configured; Disabled stops the agent naming the account (the portal’s Last user column) and the service deletes the one it heldFleetReportLastLoggedOnUser = 0 to turn off
Azure cloud environment (Identity and Authentication)Public (default; can stay Not Configured)USGOV. Required in Entra ID mode: without it agents sign in at login.microsoftonline.com and fail. Harmless with device certificatesAzureEnvironment
Device tag, organizational unit and environmentOptional labels, e.g. Pilot / IT / ProductionDeviceTag, OrgUnit, Environment

Leave Fleet agent timing and Fleet agent limits Not Configured unless you have a reason. Full value reference →

4. Pilot, then widen

  • Pilot first. With AutoApproveAgents = false (or the device identity approval rule left at Wait for an administrator), registrations arrive as PendingApproval; approve them in the portal.
  • Enrollment (optional, per tenant). Mint a single-use secret in the portal and place it in %ProgramData%\Highwind\fleet\enrollment.secret (ACL: SYSTEM and Administrators). The agent redeems and deletes it on next start.
Check a device
Get-Service HighwindFleetAgent
Get-WinEvent -LogName Application -MaxEvents 50 | Where-Object ProviderName -eq 'HighwindFleetAgent'
Get-Content "$env:ProgramData\Highwind\fleet\audit.jsonl" -Tail 20      # device certificates: look for DeviceAttested
dsregcmd /status | Select-String "AzureAdJoined|DomainJoined|DeviceId|TpmProtected"

If the desktop app is also open on a device where the service is running, the app stands down so the device is not counted twice.