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
.hwliclicense 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.
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
-WhatIffirst. 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.
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.
| Size | Devices | Portal users | App Service plan | SQL database | Pick it for |
|---|---|---|---|---|---|
| Small (default) | up to about 1,000 | 1 to 5 | B1 x 1 (1 core, 1.75 GB) | S1 (20 DTU) | Pilot, lab, small organization |
| Medium | 1,000 to 25,000 | up to 25 | P2v3 x 1 (4 cores, 16 GB) | S4 (200 DTU) | One or a few offices, mid-size company. Devices check in every 2 minutes. |
| Large | 25,000 to 150,000 | up to 50 | P3v3 x 2 (8 cores, 32 GB each) | General Purpose, 4 vCores | Large organization. Devices check in every 5 minutes. |
| XLarge | 150,000 to 600,000 | 50 or more | P3v3 x 3 (8 cores, 32 GB each) | Hyperscale, 8 vCores | Enterprise 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,-PlanInstancesand-DatabaseSkuoverride 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
sqlcmdis 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
| Option | What it does |
|---|---|
-Size | Small, Medium, Large or XLarge. |
-Location | Region to deploy to. |
-FindRegion | If the region cannot host the size, use the first one that can, without asking. |
-ResourceGroup | Use another resource group. |
-ApiRegistrationName, -PortalRegistrationName | Each 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. |
-AdminUpn | Extra users to make Highwind.Admin. You are added automatically. |
-SqlServerName, -ApiAppName, -PortalAppName, -KeyVaultName | Use existing names. |
-SkipCodeDeploy | Infrastructure and settings only. |
-RotatePortalSecret | Issue a new portal client secret. |
-AutoApproveAgents, -RequireEnrollment, -AllowModelEgress | Fleet policy for step 5. |
-DeviceCaKeyProtection Software, -AllowGroupApprovalRule, -SkipDeviceIdentity | Device 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 withaz login --tenant <tenant-id>. - sqlcmd, Go edition:
winget install sqlcmd. Its-Gsigns in with youraz loginaccount. Check withsqlcmd --version, which should printsqlcmd: v1.x. If that command errors, an older ODBC sqlcmd (installed with SQL Server tools) comes first on your PATH. Its-Gmeans 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, includesfleet-schema.sql) andHighwind-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-NoDownloadto 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.
# 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
$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 asapi://contoso.com/highwind-fleetalso works; if you use one, set$apiAudienceto it. Whatever you choose, the same value goes in the API’sAudience, the portal’s scope and every endpoint’sFleetManagementAudience. - 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:
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.
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.
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.
$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.
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
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
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
# 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-objectivewithout redeploying anything. - Serverless: avoid serverless with auto-pause. After an idle period the first heartbeat wakes the database, and
/health/readyand 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 35if your policy requires it.
2d. Network access
| Option | When | How |
|---|---|---|
| Public endpoint + firewall | Pilot, evaluation | Allow 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 endpoint | Production | Create 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. |
# 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
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.
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
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:
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
__ 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
ValidIssuersempty, 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,__2and 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.
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
# 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.
/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
$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
$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.
$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
# 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
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.
$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
$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" }
$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:
- 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. - 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. - 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
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.
$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.
$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:
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
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
| Parameter | Use it when |
|---|---|
-DeviceDomain | Always, with one of your seeded AuthorizedDomains |
-ModelEgressAllowed | The tenant was seeded with AllowModelEgress = true (otherwise the script expects a refusal) |
-OperatorToken / -AdminToken | You want to prove that an operator is refused admin actions. Pass a token from someone with Highwind.Operator only. |
-OtherTenantOperatorToken | The API serves a second tenant and you want the cross-tenant isolation check |
-IncludeAppControlUpload | You want to test policy upload. The repository is append-only, so the test policies stay. |
-KeepDevice | You want to leave the test device active instead of revoking it |
-AuthorityHost | Sovereign 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.
[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
AdminWorkstationSQL 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.
| Devices | Identity | Set up with |
|---|---|---|
| Entra joined and hybrid joined devices (recommended) | A device certificate issued by this service, per device, key in the TPM. Nothing to distribute | Device certificates below, plus step 7 once |
| Other workstations and on-premises servers | One agent app registration for the estate, signing in with a certificate | Option A below |
| Azure VMs and Azure Arc–enabled servers | The machine’s managed identity; nothing to distribute | Option B below |
| AD-only, workgroup or disconnected devices | A 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:
- finds the device’s Entra certificate (
MS-Organization-Access, created at join) and creates its own key in the TPM; - 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;
- 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 /statusshowsAzureAdJoined : 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.
$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.
$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.
$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 setting | Value | Registry value |
|---|---|---|
| Enable the fleet agent | Enabled | FleetDistributedAgentsEnabled = 1 |
| Allow the agent to register this device | Enabled | FleetAgentRegistrationEnabled = 1 |
| Allow the agent to send heartbeats | Enabled | FleetAgentHeartbeatsEnabled = 1 |
| Allow the agent to advertise its capabilities | Enabled (to answer requests) | FleetCapabilityDiscoveryEnabled = 1 |
| Allow the agent to receive requests | Enabled (to answer requests) | FleetBrokeredMessagingEnabled = 1 |
| Fleet management service → Management service URL | https://highwind-fleet-api-01.azurewebsites.nethttps://highwind-fleet-api-01.azurewebsites.usInclude https://; a bare host name is rejected | FleetManagementServiceUri |
| Fleet management service → Entra ID audience | Entra 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 /.default | FleetManagementAudience |
| Fleet management service → Expected tenant | Optional: your tenant ID. A device joined to another tenant stops | FleetExpectedTenantId |
| Fleet agent authentication mode | Device certificate for Entra joined and hybrid joined devices; Entra ID (options A and B) or Client certificate (option C) otherwise | FleetAuthenticationMode |
| Fleet agent authentication mode → Require a hardware-protected (TPM) key | Optional. Ticked: a device with no usable TPM does not join | FleetRequireHardwareKey = 1 |
| Accept client configuration from the management portal | Optional. On when not configured: reports the portal sets (Admin › Client configuration) win over the values above. Disabled makes the device use local policy only | FleetPortalConfigurationEnabled = 0 to ignore the portal |
| Report the last signed-in user | Optional. On when not configured; Disabled stops the agent naming the account (the portal’s Last user column) and the service deletes the one it held | FleetReportLastLoggedOnUser = 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 certificates | AzureEnvironment |
| Device tag, organizational unit and environment | Optional labels, e.g. Pilot / IT / Production | DeviceTag, 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.
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.