diff --git a/.env.example b/.env.example index 59bfda9..2c5d718 100644 --- a/.env.example +++ b/.env.example @@ -5,17 +5,19 @@ # NEVER commit .env to source control! # ============================================================================= -# Azure Data Explorer +# Backend choice: 'ADX' or 'LogAnalytics' +ANALYTICS_BACKEND=ADX + +# ---- ADX Backend ---- ADX_CLUSTER_URI=https://yourcluster.region.kusto.windows.net ADX_DATABASE=IntuneAnalytics -# Azure AD / Entra ID (for local development with Service Principal) -TENANT_ID=your-tenant-id -CLIENT_ID=your-app-registration-client-id -CLIENT_SECRET=your-app-registration-client-secret +# ---- Log Analytics Backend ---- +# LOG_ANALYTICS_DCE=https://your-dce.uksouth.ingest.monitor.azure.com +# LOG_ANALYTICS_DCR_ID=dcr-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +# LOG_ANALYTICS_WORKSPACE_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx -# Optional: Override function behavior -# SKIP_COMPLIANCE_STATES=false -# SKIP_ENDPOINT_ANALYTICS=false -# MAX_DEVICES=0 -# DRY_RUN=false +# ---- App Registration Credentials ---- +AZURE_TENANT_ID=your-tenant-id +AZURE_CLIENT_ID=your-app-registration-client-id +AZURE_CLIENT_SECRET=your-app-registration-client-secret diff --git a/deployment/adx/main.bicep b/deployment/adx/main.bicep index 1d3a26b..d9f9a71 100644 --- a/deployment/adx/main.bicep +++ b/deployment/adx/main.bicep @@ -1,11 +1,16 @@ // ============================================================================ // Intune Analytics Platform - ADX Backend // ============================================================================ -// This template deploys an Azure Function App with connection string auth -// for deployment storage (simpler setup, no role assignments needed). +// Deploys a Function App that exports Intune data to Azure Data Explorer. +// +// After deployment: +// 1. Add app registration credentials in Function App > Configuration: +// - AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET +// 2. Grant Graph API permissions to your app registration +// 3. Upload function code via Deployment Center // ============================================================================ -@description('Base name for all resources (max 11 characters, will be appended with unique suffix)') +@description('Base name for all resources (max 11 chars)') @maxLength(11) param baseName string @@ -16,221 +21,99 @@ param location string = resourceGroup().location param adxClusterUri string = '' @description('Azure Data Explorer database name') -param adxDatabaseName string = 'IntuneAnalytics' +param adxDatabase string = 'IntuneAnalytics' // ============================================================================ // Variables // ============================================================================ -var uniqueSuffix = uniqueString(resourceGroup().id) -var storageAccountName = toLower('${take(baseName, 11)}${take(uniqueSuffix, 13)}') -var functionAppName = '${baseName}-func-${uniqueSuffix}' -var appServicePlanName = '${baseName}-asp-${uniqueSuffix}' -var managedIdentityName = '${baseName}-mi-${uniqueSuffix}' +var suffix = uniqueString(resourceGroup().id) +var storageName = toLower('${take(baseName, 11)}${take(suffix, 13)}') +var funcName = '${baseName}-func-${suffix}' +var planName = '${baseName}-plan-${suffix}' // ============================================================================ -// User-Assigned Managed Identity (for Graph API access) +// Storage Account (required for Function App) // ============================================================================ -resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { - name: managedIdentityName +resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = { + name: storageName location: location -} - -// ============================================================================ -// Storage Account -// ============================================================================ - -resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = { - name: storageAccountName - location: location - sku: { - name: 'Standard_LRS' - } + sku: { name: 'Standard_LRS' } kind: 'StorageV2' properties: { supportsHttpsTrafficOnly: true minimumTlsVersion: 'TLS1_2' allowBlobPublicAccess: false - defaultToOAuthAuthentication: true - } -} - -resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' = { - parent: storageAccount - name: 'default' -} - -resource deploymentContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = { - parent: blobService - name: 'deploymentpackage' - properties: { - publicAccess: 'None' - } -} - -// ============================================================================ -// Deployment Script: Copy function app code from GitHub to storage -// ============================================================================ - -resource deploymentScript 'Microsoft.Resources/deploymentScripts@2023-08-01' = { - name: '${baseName}-deploy-code' - location: location - kind: 'AzureCLI' - identity: { - type: 'UserAssigned' - userAssignedIdentities: { - '${managedIdentity.id}': {} - } } - properties: { - azCliVersion: '2.52.0' - timeout: 'PT10M' - retentionInterval: 'PT1H' - cleanupPreference: 'OnSuccess' - environmentVariables: [ - { name: 'STORAGE_ACCOUNT', value: storageAccount.name } - { name: 'STORAGE_KEY', secureValue: storageAccount.listKeys().keys[0].value } - { name: 'CONTAINER_NAME', value: 'deploymentpackage' } - { name: 'ZIP_URL', value: 'https://github.com/JacobWLMS/IntuneReporting/releases/download/latest/released-package.zip' } - ] - scriptContent: ''' -set -e -echo "Starting deployment script..." -echo "Downloading from GitHub: $ZIP_URL" -curl -L -f -o /tmp/released-package.zip "$ZIP_URL" -echo "Download complete. File size: $(stat -c%s /tmp/released-package.zip) bytes" -echo "Uploading to storage account: $STORAGE_ACCOUNT" -az storage blob upload \ - --account-name "$STORAGE_ACCOUNT" \ - --account-key "$STORAGE_KEY" \ - --container-name "$CONTAINER_NAME" \ - --name "released-package.zip" \ - --file /tmp/released-package.zip \ - --overwrite -echo "Upload complete" -echo "{\"status\": \"success\", \"blobName\": \"released-package.zip\"}" > $AZ_SCRIPTS_OUTPUT_PATH - ''' - } - dependsOn: [ - deploymentContainer - ] } // ============================================================================ // App Service Plan (Flex Consumption) // ============================================================================ -resource appServicePlan 'Microsoft.Web/serverfarms@2024-04-01' = { - name: appServicePlanName +resource plan 'Microsoft.Web/serverfarms@2024-04-01' = { + name: planName location: location - sku: { - name: 'FC1' - tier: 'FlexConsumption' - } + sku: { name: 'FC1', tier: 'FlexConsumption' } kind: 'functionapp' - properties: { - reserved: true - } + properties: { reserved: true } } // ============================================================================ // Function App // ============================================================================ -resource functionApp 'Microsoft.Web/sites@2024-04-01' = { - name: functionAppName +resource func 'Microsoft.Web/sites@2024-04-01' = { + name: funcName location: location kind: 'functionapp,linux' - identity: { - type: 'UserAssigned' - userAssignedIdentities: { - '${managedIdentity.id}': {} - } - } properties: { - serverFarmId: appServicePlan.id + serverFarmId: plan.id httpsOnly: true functionAppConfig: { - deployment: { - storage: { - type: 'blobContainer' - value: '${storageAccount.properties.primaryEndpoints.blob}deploymentpackage' - authentication: { - type: 'StorageAccountConnectionString' - storageAccountConnectionStringName: 'DEPLOYMENT_STORAGE_CONNECTION_STRING' - } - } - } scaleAndConcurrency: { - maximumInstanceCount: 100 + maximumInstanceCount: 40 instanceMemoryMB: 2048 } - runtime: { - name: 'python' - version: '3.11' - } + runtime: { name: 'python', version: '3.11' } } siteConfig: { appSettings: [ - // Deployment storage connection string - { - name: 'DEPLOYMENT_STORAGE_CONNECTION_STRING' - value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};AccountKey=${storageAccount.listKeys().keys[0].value};EndpointSuffix=${environment().suffixes.storage}' - } - // AzureWebJobsStorage connection string - { - name: 'AzureWebJobsStorage' - value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};AccountKey=${storageAccount.listKeys().keys[0].value};EndpointSuffix=${environment().suffixes.storage}' - } - // Function runtime settings - { - name: 'FUNCTIONS_EXTENSION_VERSION' - value: '~4' - } - { - name: 'SCM_DO_BUILD_DURING_DEPLOYMENT' - value: 'true' - } - // Analytics backend configuration - { - name: 'ANALYTICS_BACKEND' - value: 'ADX' - } - { - name: 'ADX_CLUSTER_URI' - value: adxClusterUri - } - { - name: 'ADX_DATABASE' - value: adxDatabaseName - } - { - name: 'TENANT_ID' - value: subscription().tenantId - } - // Managed Identity client ID for Graph API calls - { - name: 'AZURE_CLIENT_ID' - value: managedIdentity.properties.clientId - } + { name: 'AzureWebJobsStorage', value: 'DefaultEndpointsProtocol=https;AccountName=${storage.name};AccountKey=${storage.listKeys().keys[0].value};EndpointSuffix=${environment().suffixes.storage}' } + { name: 'FUNCTIONS_EXTENSION_VERSION', value: '~4' } + { name: 'SCM_DO_BUILD_DURING_DEPLOYMENT', value: 'true' } + { name: 'ANALYTICS_BACKEND', value: 'ADX' } + { name: 'ADX_CLUSTER_URI', value: adxClusterUri } + { name: 'ADX_DATABASE', value: adxDatabase } + // Add these manually in portal after deployment: + // AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET ] } } - dependsOn: [ - deploymentScript - ] } // ============================================================================ // Outputs // ============================================================================ -output functionAppName string = functionApp.name -output functionAppUrl string = 'https://${functionApp.properties.defaultHostName}' -output managedIdentityObjectId string = managedIdentity.properties.principalId -output managedIdentityClientId string = managedIdentity.properties.clientId -output storageAccountName string = storageAccount.name +output functionAppName string = func.name +output functionAppUrl string = 'https://${func.properties.defaultHostName}' +output storageAccountName string = storage.name + +output postDeploymentSteps string = ''' +After deployment, complete these steps: + +1. Add app registration credentials in Function App > Configuration > Application settings: + - AZURE_TENANT_ID = your tenant ID + - AZURE_CLIENT_ID = your app registration client ID + - AZURE_CLIENT_SECRET = your app registration client secret + +2. Grant Graph API permissions to your app registration: + - DeviceManagementManagedDevices.Read.All + - DeviceManagementConfiguration.Read.All + +3. Grant your app registration "Ingestor" role on the ADX database -output nextStep string = 'IMPORTANT: Run scripts/Grant-GraphPermissions.ps1 to grant Microsoft Graph API permissions to the Managed Identity.' -output grantPermissionsCommand string = '.\\scripts\\Grant-GraphPermissions.ps1 -ManagedIdentityObjectId "${managedIdentity.properties.principalId}"' +4. Upload function code via Deployment Center (ZIP deploy) +''' diff --git a/deployment/adx/main.bicepparam b/deployment/adx/main.bicepparam index 69f9d47..7f7f2a7 100644 --- a/deployment/adx/main.bicepparam +++ b/deployment/adx/main.bicepparam @@ -1,7 +1,10 @@ using 'main.bicep' -// Example parameter values - customize as needed +// Customize these values param baseName = 'intune' param location = 'uksouth' + +// Optional: Set these if you already have an ADX cluster +// Otherwise leave empty and set after deployment param adxClusterUri = '' -param adxDatabaseName = 'IntuneAnalytics' +param adxDatabase = 'IntuneAnalytics' diff --git a/deployment/deploy-cosmosdb.json b/deployment/deploy-cosmosdb.json deleted file mode 100644 index a507fc4..0000000 --- a/deployment/deploy-cosmosdb.json +++ /dev/null @@ -1,520 +0,0 @@ -{ - "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", - "contentVersion": "1.0.0.0", - "metadata": { - "_generator": { - "name": "intune-analytics-platform", - "version": "2.1.0" - }, - "description": "[COMING SOON] Deploy Intune Analytics Platform with Cosmos DB Free Tier - Lower cost alternative to ADX. This template is not yet functional.", - "status": "coming-soon" - }, - "parameters": { - "baseName": { - "type": "string", - "defaultValue": "intune-analytics", - "metadata": { - "description": "Base name for all resources (max 15 chars)" - }, - "maxLength": 15 - }, - "location": { - "type": "string", - "defaultValue": "[resourceGroup().location]", - "metadata": { - "description": "Location for all resources" - } - }, - "repoUrl": { - "type": "string", - "defaultValue": "https://github.com/jacobwlms/IntuneReporting", - "metadata": { - "description": "GitHub repository URL containing the function code" - } - }, - "repoBranch": { - "type": "string", - "defaultValue": "main", - "metadata": { - "description": "[COMING SOON] Branch to deploy from. Cosmos DB support not yet implemented." - } - }, - "enableFreeTier": { - "type": "bool", - "defaultValue": true, - "metadata": { - "description": "Enable Cosmos DB free tier (1 per subscription)" - } - } - }, - "variables": { - "suffix": "[uniqueString(resourceGroup().id)]", - "functionAppName": "[concat(parameters('baseName'), '-fn-', take(variables('suffix'), 6))]", - "storageAccountName": "[concat(replace(parameters('baseName'), '-', ''), take(variables('suffix'), 8))]", - "appServicePlanName": "[concat(parameters('baseName'), '-plan')]", - "appInsightsName": "[concat(parameters('baseName'), '-ai')]", - "cosmosAccountName": "[concat(replace(parameters('baseName'), '-', ''), 'cosmos')]", - "cosmosDatabaseName": "IntuneAnalytics", - "deployIdentityName": "[concat(parameters('baseName'), '-deploy-id')]" - }, - "resources": [ - { - "comments": "Storage account for Function App", - "type": "Microsoft.Storage/storageAccounts", - "apiVersion": "2023-01-01", - "name": "[variables('storageAccountName')]", - "location": "[parameters('location')]", - "sku": { - "name": "Standard_LRS" - }, - "kind": "StorageV2", - "properties": { - "minimumTlsVersion": "TLS1_2", - "supportsHttpsTrafficOnly": true, - "allowBlobPublicAccess": false - } - }, - { - "comments": "Application Insights for monitoring", - "type": "Microsoft.Insights/components", - "apiVersion": "2020-02-02", - "name": "[variables('appInsightsName')]", - "location": "[parameters('location')]", - "kind": "web", - "properties": { - "Application_Type": "web" - } - }, - { - "comments": "Consumption plan for Function App (free tier)", - "type": "Microsoft.Web/serverfarms", - "apiVersion": "2022-09-01", - "name": "[variables('appServicePlanName')]", - "location": "[parameters('location')]", - "sku": { - "name": "Y1", - "tier": "Dynamic", - "size": "Y1", - "family": "Y" - }, - "properties": { - "reserved": true - } - }, - { - "comments": "Cosmos DB Account with Free Tier", - "type": "Microsoft.DocumentDB/databaseAccounts", - "apiVersion": "2024-02-15-preview", - "name": "[variables('cosmosAccountName')]", - "location": "[parameters('location')]", - "kind": "GlobalDocumentDB", - "properties": { - "enableFreeTier": "[parameters('enableFreeTier')]", - "databaseAccountOfferType": "Standard", - "consistencyPolicy": { - "defaultConsistencyLevel": "Session" - }, - "locations": [ - { - "locationName": "[parameters('location')]", - "failoverPriority": 0 - } - ], - "capabilities": [], - "enableAutomaticFailover": false, - "enableMultipleWriteLocations": false - } - }, - { - "comments": "Cosmos DB Database", - "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases", - "apiVersion": "2024-02-15-preview", - "name": "[concat(variables('cosmosAccountName'), '/', variables('cosmosDatabaseName'))]", - "dependsOn": [ - "[resourceId('Microsoft.DocumentDB/databaseAccounts', variables('cosmosAccountName'))]" - ], - "properties": { - "resource": { - "id": "[variables('cosmosDatabaseName')]" - } - } - }, - { - "comments": "Container: ManagedDevices", - "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers", - "apiVersion": "2024-02-15-preview", - "name": "[concat(variables('cosmosAccountName'), '/', variables('cosmosDatabaseName'), '/ManagedDevices')]", - "dependsOn": [ - "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', variables('cosmosAccountName'), variables('cosmosDatabaseName'))]" - ], - "properties": { - "resource": { - "id": "ManagedDevices", - "partitionKey": { - "paths": ["/deviceId"], - "kind": "Hash" - }, - "defaultTtl": 604800, - "indexingPolicy": { - "automatic": true, - "indexingMode": "consistent", - "includedPaths": [ - { "path": "/complianceState/?" }, - { "path": "/operatingSystem/?" }, - { "path": "/lastSyncDateTime/?" }, - { "path": "/userPrincipalName/?" } - ], - "excludedPaths": [ - { "path": "/*" } - ] - } - } - } - }, - { - "comments": "Container: CompliancePolicies", - "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers", - "apiVersion": "2024-02-15-preview", - "name": "[concat(variables('cosmosAccountName'), '/', variables('cosmosDatabaseName'), '/CompliancePolicies')]", - "dependsOn": [ - "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', variables('cosmosAccountName'), variables('cosmosDatabaseName'))]" - ], - "properties": { - "resource": { - "id": "CompliancePolicies", - "partitionKey": { - "paths": ["/policyId"], - "kind": "Hash" - }, - "defaultTtl": 604800 - } - } - }, - { - "comments": "Container: DeviceComplianceStates", - "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers", - "apiVersion": "2024-02-15-preview", - "name": "[concat(variables('cosmosAccountName'), '/', variables('cosmosDatabaseName'), '/DeviceComplianceStates')]", - "dependsOn": [ - "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', variables('cosmosAccountName'), variables('cosmosDatabaseName'))]" - ], - "properties": { - "resource": { - "id": "DeviceComplianceStates", - "partitionKey": { - "paths": ["/deviceId"], - "kind": "Hash" - }, - "defaultTtl": 604800, - "indexingPolicy": { - "automatic": true, - "indexingMode": "consistent", - "includedPaths": [ - { "path": "/status/?" }, - { "path": "/policyId/?" } - ], - "excludedPaths": [ - { "path": "/*" } - ] - } - } - } - }, - { - "comments": "Container: DeviceScores", - "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers", - "apiVersion": "2024-02-15-preview", - "name": "[concat(variables('cosmosAccountName'), '/', variables('cosmosDatabaseName'), '/DeviceScores')]", - "dependsOn": [ - "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', variables('cosmosAccountName'), variables('cosmosDatabaseName'))]" - ], - "properties": { - "resource": { - "id": "DeviceScores", - "partitionKey": { - "paths": ["/deviceId"], - "kind": "Hash" - }, - "defaultTtl": 604800, - "indexingPolicy": { - "automatic": true, - "indexingMode": "consistent", - "includedPaths": [ - { "path": "/endpointAnalyticsScore/?" }, - { "path": "/healthStatus/?" } - ], - "excludedPaths": [ - { "path": "/*" } - ] - } - } - } - }, - { - "comments": "Container: StartupPerformance", - "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers", - "apiVersion": "2024-02-15-preview", - "name": "[concat(variables('cosmosAccountName'), '/', variables('cosmosDatabaseName'), '/StartupPerformance')]", - "dependsOn": [ - "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', variables('cosmosAccountName'), variables('cosmosDatabaseName'))]" - ], - "properties": { - "resource": { - "id": "StartupPerformance", - "partitionKey": { - "paths": ["/deviceId"], - "kind": "Hash" - }, - "defaultTtl": 2592000 - } - } - }, - { - "comments": "Container: AppReliability", - "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers", - "apiVersion": "2024-02-15-preview", - "name": "[concat(variables('cosmosAccountName'), '/', variables('cosmosDatabaseName'), '/AppReliability')]", - "dependsOn": [ - "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', variables('cosmosDatabaseName'), variables('cosmosDatabaseName'))]" - ], - "properties": { - "resource": { - "id": "AppReliability", - "partitionKey": { - "paths": ["/appName"], - "kind": "Hash" - }, - "defaultTtl": 604800 - } - } - }, - { - "comments": "Container: SyncState", - "type": "Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers", - "apiVersion": "2024-02-15-preview", - "name": "[concat(variables('cosmosAccountName'), '/', variables('cosmosDatabaseName'), '/SyncState')]", - "dependsOn": [ - "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlDatabases', variables('cosmosAccountName'), variables('cosmosDatabaseName'))]" - ], - "properties": { - "resource": { - "id": "SyncState", - "partitionKey": { - "paths": ["/exportType"], - "kind": "Hash" - }, - "defaultTtl": -1 - } - } - }, - { - "comments": "Function App with managed identity", - "type": "Microsoft.Web/sites", - "apiVersion": "2022-09-01", - "name": "[variables('functionAppName')]", - "location": "[parameters('location')]", - "kind": "functionapp,linux", - "identity": { - "type": "SystemAssigned" - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/serverfarms', variables('appServicePlanName'))]", - "[resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName'))]", - "[resourceId('Microsoft.Insights/components', variables('appInsightsName'))]", - "[resourceId('Microsoft.DocumentDB/databaseAccounts', variables('cosmosAccountName'))]" - ], - "properties": { - "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('appServicePlanName'))]", - "httpsOnly": true, - "siteConfig": { - "linuxFxVersion": "PYTHON|3.11", - "pythonVersion": "3.11", - "appSettings": [ - { - "name": "AzureWebJobsStorage", - "value": "[concat('DefaultEndpointsProtocol=https;AccountName=', variables('storageAccountName'), ';EndpointSuffix=', environment().suffixes.storage, ';AccountKey=', listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('storageAccountName')), '2023-01-01').keys[0].value)]" - }, - { - "name": "FUNCTIONS_EXTENSION_VERSION", - "value": "~4" - }, - { - "name": "FUNCTIONS_WORKER_RUNTIME", - "value": "python" - }, - { - "name": "SCM_DO_BUILD_DURING_DEPLOYMENT", - "value": "true" - }, - { - "name": "ENABLE_ORYX_BUILD", - "value": "true" - }, - { - "name": "APPINSIGHTS_INSTRUMENTATIONKEY", - "value": "[reference(resourceId('Microsoft.Insights/components', variables('appInsightsName')), '2020-02-02').InstrumentationKey]" - }, - { - "name": "APPLICATIONINSIGHTS_CONNECTION_STRING", - "value": "[reference(resourceId('Microsoft.Insights/components', variables('appInsightsName')), '2020-02-02').ConnectionString]" - }, - { - "name": "COSMOS_ENDPOINT", - "value": "[reference(resourceId('Microsoft.DocumentDB/databaseAccounts', variables('cosmosAccountName')), '2024-02-15-preview').documentEndpoint]" - }, - { - "name": "COSMOS_DATABASE", - "value": "[variables('cosmosDatabaseName')]" - }, - { - "name": "TENANT_ID", - "value": "[subscription().tenantId]" - }, - { - "name": "DATA_BACKEND", - "value": "cosmosdb" - } - ] - } - } - }, - { - "comments": "Grant Function App data contributor access to Cosmos DB", - "type": "Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments", - "apiVersion": "2024-02-15-preview", - "name": "[concat(variables('cosmosAccountName'), '/', guid(resourceGroup().id, variables('functionAppName'), 'cosmos-contributor'))]", - "dependsOn": [ - "[resourceId('Microsoft.DocumentDB/databaseAccounts', variables('cosmosAccountName'))]", - "[resourceId('Microsoft.Web/sites', variables('functionAppName'))]" - ], - "properties": { - "roleDefinitionId": "[resourceId('Microsoft.DocumentDB/databaseAccounts/sqlRoleDefinitions', variables('cosmosAccountName'), '00000000-0000-0000-0000-000000000002')]", - "principalId": "[reference(resourceId('Microsoft.Web/sites', variables('functionAppName')), '2022-09-01', 'Full').identity.principalId]", - "scope": "[resourceId('Microsoft.DocumentDB/databaseAccounts', variables('cosmosAccountName'))]" - } - }, - { - "comments": "User-assigned managed identity for deployment scripts", - "type": "Microsoft.ManagedIdentity/userAssignedIdentities", - "apiVersion": "2023-01-31", - "name": "[variables('deployIdentityName')]", - "location": "[parameters('location')]" - }, - { - "comments": "Grant Contributor role to deployment identity", - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "name": "[guid(resourceGroup().id, variables('deployIdentityName'), 'Contributor')]", - "dependsOn": [ - "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', variables('deployIdentityName'))]" - ], - "properties": { - "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b24988ac-6180-42a0-ab88-20f7382dd24c')]", - "principalId": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', variables('deployIdentityName')), '2023-01-31').principalId]", - "principalType": "ServicePrincipal" - } - }, - { - "comments": "Deploy function code from GitHub via ZIP deploy", - "type": "Microsoft.Resources/deploymentScripts", - "apiVersion": "2023-08-01", - "name": "deployFunctionCode", - "location": "[parameters('location')]", - "kind": "AzureCLI", - "identity": { - "type": "UserAssigned", - "userAssignedIdentities": { - "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', variables('deployIdentityName'))]": {} - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', variables('functionAppName'))]", - "[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', variables('deployIdentityName'))]", - "[resourceId('Microsoft.Authorization/roleAssignments', guid(resourceGroup().id, variables('deployIdentityName'), 'Contributor'))]" - ], - "properties": { - "azCliVersion": "2.52.0", - "timeout": "PT15M", - "retentionInterval": "PT1H", - "environmentVariables": [ - { - "name": "FUNCTION_APP_NAME", - "value": "[variables('functionAppName')]" - }, - { - "name": "RESOURCE_GROUP", - "value": "[resourceGroup().name]" - }, - { - "name": "REPO_URL", - "value": "[parameters('repoUrl')]" - }, - { - "name": "REPO_BRANCH", - "value": "[parameters('repoBranch')]" - }, - { - "name": "SUBSCRIPTION_ID", - "value": "[subscription().subscriptionId]" - } - ], - "scriptContent": "#!/bin/bash\nset -e\n\n# Login with managed identity\necho 'Authenticating with managed identity...'\naz login --identity\naz account set --subscription \"$SUBSCRIPTION_ID\"\n\n# Download repo as ZIP\nREPO_ZIP_URL=\"${REPO_URL}/archive/refs/heads/${REPO_BRANCH}.zip\"\necho \"Downloading from: $REPO_ZIP_URL\"\ncurl -sL -o repo.zip \"$REPO_ZIP_URL\"\n\n# Extract\nunzip -q repo.zip\nREPO_DIR=$(ls -d */ | head -1)\ncd \"$REPO_DIR\"\n\n# Create deployment package (only function files)\necho 'Creating deployment package...'\nzip -rq ../deploy.zip host.json requirements.txt fn_* shared/\ncd ..\n\n# Deploy to Function App\necho 'Deploying to Function App...'\naz functionapp deployment source config-zip \\\n --resource-group \"$RESOURCE_GROUP\" \\\n --name \"$FUNCTION_APP_NAME\" \\\n --src deploy.zip \\\n --timeout 300\n\necho 'Deployment complete!'\necho \"{\\\"status\\\": \\\"success\\\"}\" > $AZ_SCRIPTS_OUTPUT_PATH" - } - }, - { - "comments": "Assign Graph API permissions to Function App managed identity", - "type": "Microsoft.Resources/deploymentScripts", - "apiVersion": "2023-08-01", - "name": "assignGraphPermissions", - "location": "[parameters('location')]", - "kind": "AzurePowerShell", - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', variables('functionAppName'))]" - ], - "properties": { - "azPowerShellVersion": "11.0", - "timeout": "PT10M", - "retentionInterval": "PT1H", - "arguments": "[format(' -PrincipalId {0} -TenantId {1}', reference(resourceId('Microsoft.Web/sites', variables('functionAppName')), '2022-09-01', 'Full').identity.principalId, subscription().tenantId)]", - "scriptContent": "param([string]$PrincipalId, [string]$TenantId)\n\n$ErrorActionPreference = 'Stop'\n\n# Microsoft Graph App ID (constant)\n$graphAppId = '00000003-0000-0000-c000-000000000000'\n\n# Required permissions\n$permissions = @(\n 'DeviceManagementManagedDevices.Read.All',\n 'DeviceManagementConfiguration.Read.All'\n)\n\ntry {\n # Get access token for Graph API\n $token = (Get-AzAccessToken -ResourceUrl 'https://graph.microsoft.com').Token\n $headers = @{ Authorization = \"Bearer $token\"; 'Content-Type' = 'application/json' }\n \n # Get Microsoft Graph service principal\n $graphSp = Invoke-RestMethod -Uri \"https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '$graphAppId'\" -Headers $headers\n $graphSpId = $graphSp.value[0].id\n $appRoles = $graphSp.value[0].appRoles\n \n # Assign each permission\n foreach ($permission in $permissions) {\n $appRole = $appRoles | Where-Object { $_.value -eq $permission }\n if ($appRole) {\n $body = @{\n principalId = $PrincipalId\n resourceId = $graphSpId\n appRoleId = $appRole.id\n } | ConvertTo-Json\n \n try {\n Invoke-RestMethod -Method Post -Uri \"https://graph.microsoft.com/v1.0/servicePrincipals/$PrincipalId/appRoleAssignments\" -Headers $headers -Body $body\n Write-Output \"Assigned: $permission\"\n } catch {\n if ($_.Exception.Response.StatusCode -eq 'Conflict') {\n Write-Output \"Already assigned: $permission\"\n } else {\n throw\n }\n }\n }\n }\n \n Write-Output 'Graph API permissions assigned successfully. Admin consent may still be required.'\n $DeploymentScriptOutputs = @{ permissionsAssigned = $permissions -join ',' }\n} catch {\n Write-Output \"Note: Auto-assignment requires the deploying user to have Application.ReadWrite.All or AppRoleAssignment.ReadWrite.All permission.\"\n Write-Output \"Manual step: Go to Entra ID > Enterprise Applications > $PrincipalId > Permissions > Grant admin consent\"\n $DeploymentScriptOutputs = @{ permissionsAssigned = 'manual-required' }\n}" - } - } - ], - "outputs": { - "functionAppName": { - "type": "string", - "value": "[variables('functionAppName')]" - }, - "functionAppUrl": { - "type": "string", - "value": "[concat('https://', reference(resourceId('Microsoft.Web/sites', variables('functionAppName')), '2022-09-01').defaultHostName)]" - }, - "functionAppPrincipalId": { - "type": "string", - "value": "[reference(resourceId('Microsoft.Web/sites', variables('functionAppName')), '2022-09-01', 'Full').identity.principalId]" - }, - "cosmosEndpoint": { - "type": "string", - "value": "[reference(resourceId('Microsoft.DocumentDB/databaseAccounts', variables('cosmosAccountName')), '2024-02-15-preview').documentEndpoint]" - }, - "cosmosDatabaseName": { - "type": "string", - "value": "[variables('cosmosDatabaseName')]" - }, - "appInsightsInstrumentationKey": { - "type": "string", - "value": "[reference(resourceId('Microsoft.Insights/components', variables('appInsightsName')), '2020-02-02').InstrumentationKey]" - }, - "nextSteps": { - "type": "object", - "value": { - "1_grantAdminConsent": "Go to Azure Portal > Entra ID > Enterprise Applications > Search for Function App name > Permissions > Click 'Grant admin consent'", - "note": "Cosmos DB free tier: 1000 RU/s + 25GB storage included" - } - }, - "estimatedMonthlyCost": { - "type": "string", - "value": "~$0-5/month (Function App consumption + minor storage costs, Cosmos DB free tier included)" - } - } -} diff --git a/deployment/loganalytics/main.bicep b/deployment/loganalytics/main.bicep index 4fc2b75..bd417a4 100644 --- a/deployment/loganalytics/main.bicep +++ b/deployment/loganalytics/main.bicep @@ -1,18 +1,23 @@ // ============================================================================ // Intune Analytics Platform - Log Analytics Backend // ============================================================================ -// This template deploys an Azure Function App with Log Analytics workspace -// using connection string auth for deployment storage. +// Deploys a Function App that exports Intune data to Log Analytics. +// +// After deployment: +// 1. Add app registration credentials in Function App > Configuration +// 2. Grant "Monitoring Metrics Publisher" role on the DCR to your app +// 3. Grant Graph API permissions to your app registration +// 4. Upload function code via Deployment Center // ============================================================================ -@description('Base name for all resources (max 11 characters, will be appended with unique suffix)') +@description('Base name for all resources (max 11 chars)') @maxLength(11) param baseName string @description('Location for all resources') param location string = resourceGroup().location -@description('Data retention in days (30 days free, then pay-per-GB)') +@description('Data retention in days (30 minimum)') @minValue(30) @maxValue(730) param retentionDays int = 30 @@ -21,478 +26,192 @@ param retentionDays int = 30 // Variables // ============================================================================ -var uniqueSuffix = uniqueString(resourceGroup().id) -var storageAccountName = toLower('${take(baseName, 11)}${take(uniqueSuffix, 13)}') -var functionAppName = '${baseName}-func-${uniqueSuffix}' -var appServicePlanName = '${baseName}-asp-${uniqueSuffix}' -var managedIdentityName = '${baseName}-mi-${uniqueSuffix}' -var workspaceName = '${baseName}-law-${uniqueSuffix}' -var dceName = '${baseName}-dce-${uniqueSuffix}' -var dcrName = '${baseName}-dcr-${uniqueSuffix}' - -// Role definition ID for Monitoring Metrics Publisher (needed for DCR) -var monitoringMetricsPublisherRoleId = '3913510d-42f4-4e42-8a64-420c390055eb' - -// ============================================================================ -// User-Assigned Managed Identity (for Graph API and DCR access) -// ============================================================================ - -resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { - name: managedIdentityName - location: location -} +var suffix = uniqueString(resourceGroup().id) +var storageName = toLower('${take(baseName, 11)}${take(suffix, 13)}') +var funcName = '${baseName}-func-${suffix}' +var planName = '${baseName}-plan-${suffix}' +var workspaceName = '${baseName}-law-${suffix}' +var dceName = '${baseName}-dce-${suffix}' +var dcrName = '${baseName}-dcr-${suffix}' // ============================================================================ // Storage Account // ============================================================================ -resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = { - name: storageAccountName +resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = { + name: storageName location: location - sku: { - name: 'Standard_LRS' - } + sku: { name: 'Standard_LRS' } kind: 'StorageV2' properties: { supportsHttpsTrafficOnly: true minimumTlsVersion: 'TLS1_2' allowBlobPublicAccess: false - defaultToOAuthAuthentication: true - } -} - -resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' = { - parent: storageAccount - name: 'default' -} - -resource deploymentContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = { - parent: blobService - name: 'deploymentpackage' - properties: { - publicAccess: 'None' } } // ============================================================================ -// GitHub Release URL for function code -// ============================================================================ - -resource deploymentScript 'Microsoft.Resources/deploymentScripts@2023-08-01' = { - name: '${baseName}-deploy-code' - location: location - kind: 'AzureCLI' - identity: { - type: 'UserAssigned' - userAssignedIdentities: { - '${managedIdentity.id}': {} - } - } - properties: { - azCliVersion: '2.52.0' - timeout: 'PT10M' - retentionInterval: 'PT1H' - cleanupPreference: 'OnSuccess' - environmentVariables: [ - { name: 'STORAGE_ACCOUNT', value: storageAccount.name } - { name: 'STORAGE_KEY', secureValue: storageAccount.listKeys().keys[0].value } - { name: 'CONTAINER_NAME', value: 'deploymentpackage' } - { name: 'ZIP_URL', value: 'https://github.com/JacobWLMS/IntuneReporting/releases/download/latest/released-package.zip' } - ] - scriptContent: ''' -set -e -echo "Starting deployment script..." -echo "Downloading from GitHub: $ZIP_URL" -curl -L -f -o /tmp/released-package.zip "$ZIP_URL" -echo "Download complete. File size: $(stat -c%s /tmp/released-package.zip) bytes" -echo "Uploading to storage account: $STORAGE_ACCOUNT" -az storage blob upload \ - --account-name "$STORAGE_ACCOUNT" \ - --account-key "$STORAGE_KEY" \ - --container-name "$CONTAINER_NAME" \ - --name "released-package.zip" \ - --file /tmp/released-package.zip \ - --overwrite -echo "Upload complete" -echo "{\"status\": \"success\", \"blobName\": \"released-package.zip\"}" > $AZ_SCRIPTS_OUTPUT_PATH - ''' - } - dependsOn: [ - deploymentContainer - ] -} - -// ============================================================================ -// Log Analytics Workspace +// Log Analytics Workspace & Custom Tables // ============================================================================ resource workspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' = { name: workspaceName location: location properties: { - sku: { - name: 'PerGB2018' - } + sku: { name: 'PerGB2018' } retentionInDays: retentionDays } } -// ============================================================================ -// Custom Tables for Intune Data -// ============================================================================ - -resource tableDevices 'Microsoft.OperationalInsights/workspaces/tables@2022-10-01' = { - parent: workspace - name: 'IntuneDevices_CL' - properties: { - plan: 'Analytics' - schema: { - name: 'IntuneDevices_CL' - columns: [ - { name: 'TimeGenerated', type: 'datetime' } - { name: 'DeviceId', type: 'string' } - { name: 'DeviceName', type: 'string' } - { name: 'UserId', type: 'string' } - { name: 'UserPrincipalName', type: 'string' } - { name: 'UserDisplayName', type: 'string' } - { name: 'OperatingSystem', type: 'string' } - { name: 'OSVersion', type: 'string' } - { name: 'ComplianceState', type: 'string' } - { name: 'ManagementAgent', type: 'string' } - { name: 'EnrolledDateTime', type: 'datetime' } - { name: 'LastSyncDateTime', type: 'datetime' } - { name: 'Model', type: 'string' } - { name: 'Manufacturer', type: 'string' } - { name: 'SerialNumber', type: 'string' } - { name: 'IsEncrypted', type: 'boolean' } - { name: 'IsSupervised', type: 'boolean' } - { name: 'AzureADDeviceId', type: 'string' } - { name: 'IngestionTime', type: 'datetime' } - { name: 'SourceSystem', type: 'string' } - ] - } - } -} - -resource tableCompliancePolicies 'Microsoft.OperationalInsights/workspaces/tables@2022-10-01' = { - parent: workspace - name: 'IntuneCompliancePolicies_CL' - properties: { - plan: 'Analytics' - schema: { - name: 'IntuneCompliancePolicies_CL' - columns: [ - { name: 'TimeGenerated', type: 'datetime' } - { name: 'PolicyId', type: 'string' } - { name: 'PolicyName', type: 'string' } - { name: 'Description', type: 'string' } - { name: 'CreatedDateTime', type: 'datetime' } - { name: 'LastModifiedDateTime', type: 'datetime' } - { name: 'PolicyType', type: 'string' } - { name: 'IngestionTime', type: 'datetime' } - { name: 'SourceSystem', type: 'string' } - ] - } - } -} - -resource tableComplianceStates 'Microsoft.OperationalInsights/workspaces/tables@2022-10-01' = { - parent: workspace - name: 'IntuneComplianceStates_CL' - properties: { - plan: 'Analytics' - schema: { - name: 'IntuneComplianceStates_CL' - columns: [ - { name: 'TimeGenerated', type: 'datetime' } - { name: 'DeviceId', type: 'string' } - { name: 'DeviceName', type: 'string' } - { name: 'UserId', type: 'string' } - { name: 'UserPrincipalName', type: 'string' } - { name: 'PolicyId', type: 'string' } - { name: 'PolicyName', type: 'string' } - { name: 'Status', type: 'string' } - { name: 'StatusRaw', type: 'int' } - { name: 'SettingCount', type: 'int' } - { name: 'FailedSettingCount', type: 'int' } - { name: 'LastContact', type: 'datetime' } - { name: 'InGracePeriodCount', type: 'int' } - { name: 'IngestionTime', type: 'datetime' } - { name: 'SourceSystem', type: 'string' } - ] - } - } -} - -resource tableDeviceScores 'Microsoft.OperationalInsights/workspaces/tables@2022-10-01' = { - parent: workspace - name: 'IntuneDeviceScores_CL' - properties: { - plan: 'Analytics' - schema: { - name: 'IntuneDeviceScores_CL' - columns: [ - { name: 'TimeGenerated', type: 'datetime' } - { name: 'DeviceId', type: 'string' } - { name: 'DeviceName', type: 'string' } - { name: 'Model', type: 'string' } - { name: 'Manufacturer', type: 'string' } - { name: 'HealthStatus', type: 'string' } - { name: 'EndpointAnalyticsScore', type: 'real' } - { name: 'StartupPerformanceScore', type: 'real' } - { name: 'AppReliabilityScore', type: 'real' } - { name: 'WorkFromAnywhereScore', type: 'real' } - { name: 'MeanResourceSpikeTimeScore', type: 'real' } - { name: 'BatteryHealthScore', type: 'real' } - { name: 'IngestionTime', type: 'datetime' } - { name: 'SourceSystem', type: 'string' } - ] - } - } -} - -resource tableStartupPerformance 'Microsoft.OperationalInsights/workspaces/tables@2022-10-01' = { - parent: workspace - name: 'IntuneStartupPerformance_CL' - properties: { - plan: 'Analytics' - schema: { - name: 'IntuneStartupPerformance_CL' - columns: [ - { name: 'TimeGenerated', type: 'datetime' } - { name: 'DeviceId', type: 'string' } - { name: 'StartTime', type: 'datetime' } - { name: 'CoreBootTimeInMs', type: 'int' } - { name: 'GroupPolicyBootTimeInMs', type: 'int' } - { name: 'GroupPolicyLoginTimeInMs', type: 'int' } - { name: 'CoreLoginTimeInMs', type: 'int' } - { name: 'TotalBootTimeInMs', type: 'int' } - { name: 'TotalLoginTimeInMs', type: 'int' } - { name: 'IsFirstLogin', type: 'boolean' } - { name: 'IsFeatureUpdate', type: 'boolean' } - { name: 'OperatingSystemVersion', type: 'string' } - { name: 'RestartCategory', type: 'string' } - { name: 'RestartFaultBucket', type: 'string' } - { name: 'IngestionTime', type: 'datetime' } - { name: 'SourceSystem', type: 'string' } - ] - } - } -} - -resource tableAppReliability 'Microsoft.OperationalInsights/workspaces/tables@2022-10-01' = { - parent: workspace - name: 'IntuneAppReliability_CL' - properties: { - plan: 'Analytics' - schema: { - name: 'IntuneAppReliability_CL' - columns: [ - { name: 'TimeGenerated', type: 'datetime' } - { name: 'AppName', type: 'string' } - { name: 'AppDisplayName', type: 'string' } - { name: 'AppPublisher', type: 'string' } - { name: 'ActiveDeviceCount', type: 'int' } - { name: 'AppCrashCount', type: 'int' } - { name: 'AppHangCount', type: 'int' } - { name: 'MeanTimeToFailureInMinutes', type: 'int' } - { name: 'AppHealthScore', type: 'real' } - { name: 'AppHealthStatus', type: 'string' } - { name: 'IngestionTime', type: 'datetime' } - { name: 'SourceSystem', type: 'string' } - ] - } - } +var tableSchemas = { + IntuneDevices_CL: [ + { name: 'TimeGenerated', type: 'datetime' } + { name: 'DeviceId', type: 'string' } + { name: 'DeviceName', type: 'string' } + { name: 'UserId', type: 'string' } + { name: 'UserPrincipalName', type: 'string' } + { name: 'UserDisplayName', type: 'string' } + { name: 'OperatingSystem', type: 'string' } + { name: 'OSVersion', type: 'string' } + { name: 'ComplianceState', type: 'string' } + { name: 'ManagementAgent', type: 'string' } + { name: 'EnrolledDateTime', type: 'datetime' } + { name: 'LastSyncDateTime', type: 'datetime' } + { name: 'Model', type: 'string' } + { name: 'Manufacturer', type: 'string' } + { name: 'SerialNumber', type: 'string' } + { name: 'IsEncrypted', type: 'boolean' } + { name: 'IsSupervised', type: 'boolean' } + { name: 'AzureADDeviceId', type: 'string' } + { name: 'IngestionTime', type: 'datetime' } + { name: 'SourceSystem', type: 'string' } + ] + IntuneCompliancePolicies_CL: [ + { name: 'TimeGenerated', type: 'datetime' } + { name: 'PolicyId', type: 'string' } + { name: 'PolicyName', type: 'string' } + { name: 'Description', type: 'string' } + { name: 'CreatedDateTime', type: 'datetime' } + { name: 'LastModifiedDateTime', type: 'datetime' } + { name: 'PolicyType', type: 'string' } + { name: 'IngestionTime', type: 'datetime' } + { name: 'SourceSystem', type: 'string' } + ] + IntuneComplianceStates_CL: [ + { name: 'TimeGenerated', type: 'datetime' } + { name: 'DeviceId', type: 'string' } + { name: 'DeviceName', type: 'string' } + { name: 'UserId', type: 'string' } + { name: 'UserPrincipalName', type: 'string' } + { name: 'PolicyId', type: 'string' } + { name: 'PolicyName', type: 'string' } + { name: 'Status', type: 'string' } + { name: 'StatusRaw', type: 'int' } + { name: 'SettingCount', type: 'int' } + { name: 'FailedSettingCount', type: 'int' } + { name: 'LastContact', type: 'datetime' } + { name: 'InGracePeriodCount', type: 'int' } + { name: 'IngestionTime', type: 'datetime' } + { name: 'SourceSystem', type: 'string' } + ] + IntuneDeviceScores_CL: [ + { name: 'TimeGenerated', type: 'datetime' } + { name: 'DeviceId', type: 'string' } + { name: 'DeviceName', type: 'string' } + { name: 'Model', type: 'string' } + { name: 'Manufacturer', type: 'string' } + { name: 'HealthStatus', type: 'string' } + { name: 'EndpointAnalyticsScore', type: 'real' } + { name: 'StartupPerformanceScore', type: 'real' } + { name: 'AppReliabilityScore', type: 'real' } + { name: 'WorkFromAnywhereScore', type: 'real' } + { name: 'MeanResourceSpikeTimeScore', type: 'real' } + { name: 'BatteryHealthScore', type: 'real' } + { name: 'IngestionTime', type: 'datetime' } + { name: 'SourceSystem', type: 'string' } + ] + IntuneStartupPerformance_CL: [ + { name: 'TimeGenerated', type: 'datetime' } + { name: 'DeviceId', type: 'string' } + { name: 'StartTime', type: 'datetime' } + { name: 'CoreBootTimeInMs', type: 'int' } + { name: 'GroupPolicyBootTimeInMs', type: 'int' } + { name: 'GroupPolicyLoginTimeInMs', type: 'int' } + { name: 'CoreLoginTimeInMs', type: 'int' } + { name: 'TotalBootTimeInMs', type: 'int' } + { name: 'TotalLoginTimeInMs', type: 'int' } + { name: 'IsFirstLogin', type: 'boolean' } + { name: 'IsFeatureUpdate', type: 'boolean' } + { name: 'OperatingSystemVersion', type: 'string' } + { name: 'RestartCategory', type: 'string' } + { name: 'RestartFaultBucket', type: 'string' } + { name: 'IngestionTime', type: 'datetime' } + { name: 'SourceSystem', type: 'string' } + ] + IntuneAppReliability_CL: [ + { name: 'TimeGenerated', type: 'datetime' } + { name: 'AppName', type: 'string' } + { name: 'AppDisplayName', type: 'string' } + { name: 'AppPublisher', type: 'string' } + { name: 'ActiveDeviceCount', type: 'int' } + { name: 'AppCrashCount', type: 'int' } + { name: 'AppHangCount', type: 'int' } + { name: 'MeanTimeToFailureInMinutes', type: 'int' } + { name: 'AppHealthScore', type: 'real' } + { name: 'AppHealthStatus', type: 'string' } + { name: 'IngestionTime', type: 'datetime' } + { name: 'SourceSystem', type: 'string' } + ] + IntuneSyncState_CL: [ + { name: 'TimeGenerated', type: 'datetime' } + { name: 'ExportType', type: 'string' } + { name: 'RecordCount', type: 'int' } + { name: 'StartTime', type: 'datetime' } + { name: 'EndTime', type: 'datetime' } + { name: 'DurationSeconds', type: 'real' } + { name: 'Status', type: 'string' } + { name: 'ErrorMessage', type: 'string' } + { name: 'IngestionTime', type: 'datetime' } + { name: 'SourceSystem', type: 'string' } + ] } -resource tableSyncState 'Microsoft.OperationalInsights/workspaces/tables@2022-10-01' = { +resource tables 'Microsoft.OperationalInsights/workspaces/tables@2022-10-01' = [for table in items(tableSchemas): { parent: workspace - name: 'IntuneSyncState_CL' + name: table.key properties: { plan: 'Analytics' - schema: { - name: 'IntuneSyncState_CL' - columns: [ - { name: 'TimeGenerated', type: 'datetime' } - { name: 'ExportType', type: 'string' } - { name: 'RecordCount', type: 'int' } - { name: 'StartTime', type: 'datetime' } - { name: 'EndTime', type: 'datetime' } - { name: 'DurationSeconds', type: 'real' } - { name: 'Status', type: 'string' } - { name: 'ErrorMessage', type: 'string' } - { name: 'IngestionTime', type: 'datetime' } - { name: 'SourceSystem', type: 'string' } - ] - } + schema: { name: table.key, columns: table.value } } -} +}] // ============================================================================ -// Data Collection Endpoint +// Data Collection Endpoint & Rule // ============================================================================ -resource dataCollectionEndpoint 'Microsoft.Insights/dataCollectionEndpoints@2022-06-01' = { +resource dce 'Microsoft.Insights/dataCollectionEndpoints@2022-06-01' = { name: dceName location: location - properties: { - networkAcls: { - publicNetworkAccess: 'Enabled' - } - } + properties: { networkAcls: { publicNetworkAccess: 'Enabled' } } } -// ============================================================================ -// Data Collection Rule -// ============================================================================ - -resource dataCollectionRule 'Microsoft.Insights/dataCollectionRules@2022-06-01' = { +resource dcr 'Microsoft.Insights/dataCollectionRules@2022-06-01' = { name: dcrName location: location - dependsOn: [ - tableDevices - tableCompliancePolicies - tableComplianceStates - tableDeviceScores - tableStartupPerformance - tableAppReliability - tableSyncState - ] + dependsOn: [tables] properties: { - dataCollectionEndpointId: dataCollectionEndpoint.id + dataCollectionEndpointId: dce.id streamDeclarations: { - 'Custom-IntuneDevices_CL': { - columns: [ - { name: 'TimeGenerated', type: 'datetime' } - { name: 'DeviceId', type: 'string' } - { name: 'DeviceName', type: 'string' } - { name: 'UserId', type: 'string' } - { name: 'UserPrincipalName', type: 'string' } - { name: 'UserDisplayName', type: 'string' } - { name: 'OperatingSystem', type: 'string' } - { name: 'OSVersion', type: 'string' } - { name: 'ComplianceState', type: 'string' } - { name: 'ManagementAgent', type: 'string' } - { name: 'EnrolledDateTime', type: 'datetime' } - { name: 'LastSyncDateTime', type: 'datetime' } - { name: 'Model', type: 'string' } - { name: 'Manufacturer', type: 'string' } - { name: 'SerialNumber', type: 'string' } - { name: 'IsEncrypted', type: 'boolean' } - { name: 'IsSupervised', type: 'boolean' } - { name: 'AzureADDeviceId', type: 'string' } - { name: 'IngestionTime', type: 'datetime' } - { name: 'SourceSystem', type: 'string' } - ] - } - 'Custom-IntuneCompliancePolicies_CL': { - columns: [ - { name: 'TimeGenerated', type: 'datetime' } - { name: 'PolicyId', type: 'string' } - { name: 'PolicyName', type: 'string' } - { name: 'Description', type: 'string' } - { name: 'CreatedDateTime', type: 'datetime' } - { name: 'LastModifiedDateTime', type: 'datetime' } - { name: 'PolicyType', type: 'string' } - { name: 'IngestionTime', type: 'datetime' } - { name: 'SourceSystem', type: 'string' } - ] - } - 'Custom-IntuneComplianceStates_CL': { - columns: [ - { name: 'TimeGenerated', type: 'datetime' } - { name: 'DeviceId', type: 'string' } - { name: 'DeviceName', type: 'string' } - { name: 'UserId', type: 'string' } - { name: 'UserPrincipalName', type: 'string' } - { name: 'PolicyId', type: 'string' } - { name: 'PolicyName', type: 'string' } - { name: 'Status', type: 'string' } - { name: 'StatusRaw', type: 'int' } - { name: 'SettingCount', type: 'int' } - { name: 'FailedSettingCount', type: 'int' } - { name: 'LastContact', type: 'datetime' } - { name: 'InGracePeriodCount', type: 'int' } - { name: 'IngestionTime', type: 'datetime' } - { name: 'SourceSystem', type: 'string' } - ] - } - 'Custom-IntuneDeviceScores_CL': { - columns: [ - { name: 'TimeGenerated', type: 'datetime' } - { name: 'DeviceId', type: 'string' } - { name: 'DeviceName', type: 'string' } - { name: 'Model', type: 'string' } - { name: 'Manufacturer', type: 'string' } - { name: 'HealthStatus', type: 'string' } - { name: 'EndpointAnalyticsScore', type: 'real' } - { name: 'StartupPerformanceScore', type: 'real' } - { name: 'AppReliabilityScore', type: 'real' } - { name: 'WorkFromAnywhereScore', type: 'real' } - { name: 'MeanResourceSpikeTimeScore', type: 'real' } - { name: 'BatteryHealthScore', type: 'real' } - { name: 'IngestionTime', type: 'datetime' } - { name: 'SourceSystem', type: 'string' } - ] - } - 'Custom-IntuneStartupPerformance_CL': { - columns: [ - { name: 'TimeGenerated', type: 'datetime' } - { name: 'DeviceId', type: 'string' } - { name: 'StartTime', type: 'datetime' } - { name: 'CoreBootTimeInMs', type: 'int' } - { name: 'GroupPolicyBootTimeInMs', type: 'int' } - { name: 'GroupPolicyLoginTimeInMs', type: 'int' } - { name: 'CoreLoginTimeInMs', type: 'int' } - { name: 'TotalBootTimeInMs', type: 'int' } - { name: 'TotalLoginTimeInMs', type: 'int' } - { name: 'IsFirstLogin', type: 'boolean' } - { name: 'IsFeatureUpdate', type: 'boolean' } - { name: 'OperatingSystemVersion', type: 'string' } - { name: 'RestartCategory', type: 'string' } - { name: 'RestartFaultBucket', type: 'string' } - { name: 'IngestionTime', type: 'datetime' } - { name: 'SourceSystem', type: 'string' } - ] - } - 'Custom-IntuneAppReliability_CL': { - columns: [ - { name: 'TimeGenerated', type: 'datetime' } - { name: 'AppName', type: 'string' } - { name: 'AppDisplayName', type: 'string' } - { name: 'AppPublisher', type: 'string' } - { name: 'ActiveDeviceCount', type: 'int' } - { name: 'AppCrashCount', type: 'int' } - { name: 'AppHangCount', type: 'int' } - { name: 'MeanTimeToFailureInMinutes', type: 'int' } - { name: 'AppHealthScore', type: 'real' } - { name: 'AppHealthStatus', type: 'string' } - { name: 'IngestionTime', type: 'datetime' } - { name: 'SourceSystem', type: 'string' } - ] - } - 'Custom-IntuneSyncState_CL': { - columns: [ - { name: 'TimeGenerated', type: 'datetime' } - { name: 'ExportType', type: 'string' } - { name: 'RecordCount', type: 'int' } - { name: 'StartTime', type: 'datetime' } - { name: 'EndTime', type: 'datetime' } - { name: 'DurationSeconds', type: 'real' } - { name: 'Status', type: 'string' } - { name: 'ErrorMessage', type: 'string' } - { name: 'IngestionTime', type: 'datetime' } - { name: 'SourceSystem', type: 'string' } - ] - } + 'Custom-IntuneDevices_CL': { columns: tableSchemas.IntuneDevices_CL } + 'Custom-IntuneCompliancePolicies_CL': { columns: tableSchemas.IntuneCompliancePolicies_CL } + 'Custom-IntuneComplianceStates_CL': { columns: tableSchemas.IntuneComplianceStates_CL } + 'Custom-IntuneDeviceScores_CL': { columns: tableSchemas.IntuneDeviceScores_CL } + 'Custom-IntuneStartupPerformance_CL': { columns: tableSchemas.IntuneStartupPerformance_CL } + 'Custom-IntuneAppReliability_CL': { columns: tableSchemas.IntuneAppReliability_CL } + 'Custom-IntuneSyncState_CL': { columns: tableSchemas.IntuneSyncState_CL } } destinations: { - logAnalytics: [ - { - workspaceResourceId: workspace.id - name: 'workspace' - } - ] + logAnalytics: [{ workspaceResourceId: workspace.id, name: 'workspace' }] } dataFlows: [ { streams: ['Custom-IntuneDevices_CL'], destinations: ['workspace'], transformKql: 'source', outputStream: 'Custom-IntuneDevices_CL' } @@ -507,141 +226,74 @@ resource dataCollectionRule 'Microsoft.Insights/dataCollectionRules@2022-06-01' } // ============================================================================ -// App Service Plan (Flex Consumption) +// App Service Plan & Function App // ============================================================================ -resource appServicePlan 'Microsoft.Web/serverfarms@2024-04-01' = { - name: appServicePlanName +resource plan 'Microsoft.Web/serverfarms@2024-04-01' = { + name: planName location: location - sku: { - name: 'FC1' - tier: 'FlexConsumption' - } + sku: { name: 'FC1', tier: 'FlexConsumption' } kind: 'functionapp' - properties: { - reserved: true - } + properties: { reserved: true } } -// ============================================================================ -// Function App -// ============================================================================ - -resource functionApp 'Microsoft.Web/sites@2024-04-01' = { - name: functionAppName +resource func 'Microsoft.Web/sites@2024-04-01' = { + name: funcName location: location kind: 'functionapp,linux' - identity: { - type: 'UserAssigned' - userAssignedIdentities: { - '${managedIdentity.id}': {} - } - } properties: { - serverFarmId: appServicePlan.id + serverFarmId: plan.id httpsOnly: true functionAppConfig: { - deployment: { - storage: { - type: 'blobContainer' - value: '${storageAccount.properties.primaryEndpoints.blob}deploymentpackage' - authentication: { - type: 'StorageAccountConnectionString' - storageAccountConnectionStringName: 'DEPLOYMENT_STORAGE_CONNECTION_STRING' - } - } - } scaleAndConcurrency: { - maximumInstanceCount: 100 + maximumInstanceCount: 40 instanceMemoryMB: 2048 } - runtime: { - name: 'python' - version: '3.11' - } + runtime: { name: 'python', version: '3.11' } } siteConfig: { appSettings: [ - // Deployment storage connection string - { - name: 'DEPLOYMENT_STORAGE_CONNECTION_STRING' - value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};AccountKey=${storageAccount.listKeys().keys[0].value};EndpointSuffix=${environment().suffixes.storage}' - } - // AzureWebJobsStorage connection string - { - name: 'AzureWebJobsStorage' - value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};AccountKey=${storageAccount.listKeys().keys[0].value};EndpointSuffix=${environment().suffixes.storage}' - } - // Function runtime settings - { - name: 'FUNCTIONS_EXTENSION_VERSION' - value: '~4' - } - { - name: 'SCM_DO_BUILD_DURING_DEPLOYMENT' - value: 'true' - } - // Log Analytics backend configuration - { - name: 'ANALYTICS_BACKEND' - value: 'LogAnalytics' - } - { - name: 'LOG_ANALYTICS_DCE' - value: dataCollectionEndpoint.properties.logsIngestion.endpoint - } - { - name: 'LOG_ANALYTICS_DCR_ID' - value: dataCollectionRule.properties.immutableId - } - { - name: 'LOG_ANALYTICS_WORKSPACE_ID' - value: workspace.properties.customerId - } - { - name: 'TENANT_ID' - value: subscription().tenantId - } - // Managed Identity client ID for Graph API and DCR access - { - name: 'AZURE_CLIENT_ID' - value: managedIdentity.properties.clientId - } + { name: 'AzureWebJobsStorage', value: 'DefaultEndpointsProtocol=https;AccountName=${storage.name};AccountKey=${storage.listKeys().keys[0].value};EndpointSuffix=${environment().suffixes.storage}' } + { name: 'FUNCTIONS_EXTENSION_VERSION', value: '~4' } + { name: 'SCM_DO_BUILD_DURING_DEPLOYMENT', value: 'true' } + { name: 'ANALYTICS_BACKEND', value: 'LogAnalytics' } + { name: 'LOG_ANALYTICS_DCE', value: dce.properties.logsIngestion.endpoint } + { name: 'LOG_ANALYTICS_DCR_ID', value: dcr.properties.immutableId } + { name: 'LOG_ANALYTICS_WORKSPACE_ID', value: workspace.properties.customerId } + // Add these manually in portal after deployment: + // AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET ] } } - dependsOn: [ - deploymentScript - ] } // ============================================================================ -// Role Assignment: Managed Identity -> Monitoring Metrics Publisher (DCR) -// This is required for the Function App to write to Log Analytics via DCR +// Outputs // ============================================================================ -resource dcrRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(dataCollectionRule.id, managedIdentity.id, monitoringMetricsPublisherRoleId) - scope: dataCollectionRule - properties: { - principalId: managedIdentity.properties.principalId - principalType: 'ServicePrincipal' - roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', monitoringMetricsPublisherRoleId) - } -} +output functionAppName string = func.name +output functionAppUrl string = 'https://${func.properties.defaultHostName}' +output workspaceName string = workspace.name +output workspaceId string = workspace.properties.customerId +output dcrName string = dcr.name +output dcrId string = dcr.id -// ============================================================================ -// Outputs -// ============================================================================ +output postDeploymentSteps string = ''' +After deployment, complete these steps: + +1. Add app registration credentials in Function App > Configuration > Application settings: + - AZURE_TENANT_ID = your tenant ID + - AZURE_CLIENT_ID = your app registration client ID + - AZURE_CLIENT_SECRET = your app registration client secret + +2. Grant "Monitoring Metrics Publisher" role to your app registration on the DCR: + - Go to the Data Collection Rule > Access control (IAM) + - Add role assignment > Monitoring Metrics Publisher + - Select your app registration -output functionAppName string = functionApp.name -output functionAppUrl string = 'https://${functionApp.properties.defaultHostName}' -output managedIdentityObjectId string = managedIdentity.properties.principalId -output managedIdentityClientId string = managedIdentity.properties.clientId -output storageAccountName string = storageAccount.name -output logAnalyticsWorkspaceId string = workspace.properties.customerId -output logAnalyticsWorkspaceName string = workspace.name -output dataCollectionEndpoint string = dataCollectionEndpoint.properties.logsIngestion.endpoint +3. Grant Graph API permissions to your app registration: + - DeviceManagementManagedDevices.Read.All + - DeviceManagementConfiguration.Read.All -output nextStep string = 'IMPORTANT: Run scripts/Grant-GraphPermissions.ps1 to grant Microsoft Graph API permissions to the Managed Identity.' -output grantPermissionsCommand string = '.\\scripts\\Grant-GraphPermissions.ps1 -ManagedIdentityObjectId "${managedIdentity.properties.principalId}"' +4. Upload function code via Deployment Center (ZIP deploy) +''' diff --git a/fn_compliance/__init__.py b/fn_compliance/__init__.py index 03745b4..7f2c9e6 100644 --- a/fn_compliance/__init__.py +++ b/fn_compliance/__init__.py @@ -13,8 +13,8 @@ from datetime import datetime, timezone from shared import ( - Config, ADXClient, get_graph_client, add_metadata, - parse_report_response, retry_with_backoff, MAX_RETRIES, BASE_DELAY + Config, get_ingestion_client, get_graph_client, add_metadata, + parse_report_response, retry_with_backoff ) from msgraph_beta.generated.device_management.reports.get_device_status_by_compliace_policy_report.get_device_status_by_compliace_policy_report_post_request_body import GetDeviceStatusByCompliacePolicyReportPostRequestBody @@ -125,7 +125,7 @@ async def run_export(): """Main export logic""" config = Config.from_env() graph = get_graph_client() - adx = ADXClient(config) + client = get_ingestion_client(config) results = {} @@ -133,14 +133,14 @@ async def run_export(): logging.info("Fetching managed devices...") devices = await get_managed_devices(graph) devices = add_metadata(devices, 'GraphAPI') - results['devices'] = adx.ingest('ManagedDevices', devices) + results['devices'] = client.ingest('ManagedDevices', devices) logging.info(f"Ingested {results['devices']} devices") # 2. Export compliance policies (metadata) logging.info("Fetching compliance policies...") policies = await get_compliance_policies(graph) policies = add_metadata(policies, 'GraphAPI') - results['policies'] = adx.ingest('CompliancePolicies', policies) + results['policies'] = client.ingest('CompliancePolicies', policies) logging.info(f"Ingested {results['policies']} policies") # 3. Export compliance states per policy (THE ACTIONABLE DATA) @@ -153,7 +153,7 @@ async def run_export(): all_states.extend(states) all_states = add_metadata(all_states, 'GraphAPI') - results['states'] = adx.ingest('DeviceComplianceStates', all_states) + results['states'] = client.ingest('DeviceComplianceStates', all_states) logging.info(f"Ingested {results['states']} compliance states") return results diff --git a/fn_endpoint_analytics/__init__.py b/fn_endpoint_analytics/__init__.py index 4f52716..91803fa 100644 --- a/fn_endpoint_analytics/__init__.py +++ b/fn_endpoint_analytics/__init__.py @@ -12,7 +12,7 @@ import azure.functions as func from datetime import datetime, timezone -from shared import Config, ADXClient, get_graph_client, add_metadata +from shared import Config, get_ingestion_client, get_graph_client, add_metadata async def get_device_scores(graph) -> list[dict]: @@ -110,7 +110,7 @@ async def run_export(): """Main export logic""" config = Config.from_env() graph = get_graph_client() - adx = ADXClient(config) + client = get_ingestion_client(config) results = {} @@ -118,21 +118,21 @@ async def run_export(): logging.info("Fetching device health scores...") scores = await get_device_scores(graph) scores = add_metadata(scores, 'EndpointAnalytics') - results['scores'] = adx.ingest('DeviceScores', scores) + results['scores'] = client.ingest('DeviceScores', scores) logging.info(f"Ingested {results['scores']} device scores") # 2. Startup performance history logging.info("Fetching startup performance...") startup = await get_startup_performance(graph) startup = add_metadata(startup, 'EndpointAnalytics') - results['startup'] = adx.ingest('StartupPerformance', startup) + results['startup'] = client.ingest('StartupPerformance', startup) logging.info(f"Ingested {results['startup']} startup records") # 3. App reliability (which apps are causing issues) logging.info("Fetching app reliability...") apps = await get_app_reliability(graph) apps = add_metadata(apps, 'EndpointAnalytics') - results['apps'] = adx.ingest('AppReliability', apps) + results['apps'] = client.ingest('AppReliability', apps) logging.info(f"Ingested {results['apps']} app reliability records") return results diff --git a/scripts/Grant-AzureRoles.ps1 b/scripts/Grant-AzureRoles.ps1 deleted file mode 100644 index 5a96b2e..0000000 --- a/scripts/Grant-AzureRoles.ps1 +++ /dev/null @@ -1,156 +0,0 @@ -<# -.SYNOPSIS - Grants required Azure role assignments for the Intune Analytics Function App. - -.DESCRIPTION - This script grants the necessary Azure RBAC roles to the Function App's managed identity: - - Storage Blob Data Owner on the storage account (required for identity-based storage auth) - - Monitoring Metrics Publisher on the DCR (required for Log Analytics backend only) - - Run this script if you deployed with createRoleAssignments=false, or if the deployment - failed due to missing permissions. - -.PARAMETER ResourceGroupName - The name of the resource group containing the deployed resources. - -.PARAMETER ManagedIdentityName - The name of the user-assigned managed identity (optional - will auto-detect if not provided). - -.PARAMETER Backend - The analytics backend: 'ADX' or 'LogAnalytics'. Determines which role assignments to create. - -.EXAMPLE - .\Grant-AzureRoles.ps1 -ResourceGroupName "rg-intune-analytics" - -.EXAMPLE - .\Grant-AzureRoles.ps1 -ResourceGroupName "rg-intune-analytics" -Backend "LogAnalytics" -#> - -[CmdletBinding()] -param( - [Parameter(Mandatory = $true)] - [string]$ResourceGroupName, - - [Parameter()] - [string]$ManagedIdentityName, - - [Parameter()] - [ValidateSet('ADX', 'LogAnalytics')] - [string]$Backend = 'ADX' -) - -$ErrorActionPreference = "Stop" - -Write-Host "šŸ” Granting Azure role assignments for Intune Analytics..." -ForegroundColor Cyan -Write-Host " Resource Group: $ResourceGroupName" -ForegroundColor Gray -Write-Host " Backend: $Backend" -ForegroundColor Gray - -# Check if logged in to Azure -try { - $context = Get-AzContext - if (-not $context) { - Write-Host "āš ļø Not logged in to Azure. Running Connect-AzAccount..." -ForegroundColor Yellow - Connect-AzAccount - } - Write-Host " Subscription: $($context.Subscription.Name)" -ForegroundColor Gray -} catch { - Write-Error "Failed to connect to Azure. Please run Connect-AzAccount first." - exit 1 -} - -# Find the managed identity -Write-Host "`nšŸ“‹ Finding managed identity..." -ForegroundColor Cyan - -if ($ManagedIdentityName) { - $mi = Get-AzUserAssignedIdentity -ResourceGroupName $ResourceGroupName -Name $ManagedIdentityName -ErrorAction SilentlyContinue -} else { - # Auto-detect: find managed identity in resource group - $identities = Get-AzUserAssignedIdentity -ResourceGroupName $ResourceGroupName -ErrorAction SilentlyContinue - if ($identities.Count -eq 0) { - Write-Error "No managed identities found in resource group '$ResourceGroupName'." - exit 1 - } elseif ($identities.Count -gt 1) { - Write-Host " Multiple managed identities found. Please specify -ManagedIdentityName:" -ForegroundColor Yellow - $identities | ForEach-Object { Write-Host " - $($_.Name)" -ForegroundColor Gray } - exit 1 - } - $mi = $identities[0] -} - -if (-not $mi) { - Write-Error "Managed identity not found." - exit 1 -} - -Write-Host " āœ“ Found: $($mi.Name)" -ForegroundColor Green -Write-Host " Principal ID: $($mi.PrincipalId)" -ForegroundColor Gray - -# Find the storage account -Write-Host "`nšŸ“¦ Finding storage account..." -ForegroundColor Cyan -$storageAccounts = Get-AzStorageAccount -ResourceGroupName $ResourceGroupName -if ($storageAccounts.Count -eq 0) { - Write-Error "No storage accounts found in resource group." - exit 1 -} -$storage = $storageAccounts | Where-Object { $_.StorageAccountName -notlike "*diag*" } | Select-Object -First 1 -Write-Host " āœ“ Found: $($storage.StorageAccountName)" -ForegroundColor Green - -# Grant Storage Blob Data Owner role -Write-Host "`nšŸ”‘ Granting Storage Blob Data Owner role..." -ForegroundColor Cyan -$storageBlobDataOwnerRoleId = "b7e6dc6d-f1e8-4753-8033-0f276bb0955b" - -try { - $existingAssignment = Get-AzRoleAssignment -ObjectId $mi.PrincipalId -RoleDefinitionId $storageBlobDataOwnerRoleId -Scope $storage.Id -ErrorAction SilentlyContinue - if ($existingAssignment) { - Write-Host " āœ“ Role already assigned" -ForegroundColor Green - } else { - New-AzRoleAssignment -ObjectId $mi.PrincipalId -RoleDefinitionId $storageBlobDataOwnerRoleId -Scope $storage.Id | Out-Null - Write-Host " āœ“ Role assigned successfully" -ForegroundColor Green - } -} catch { - if ($_.Exception.Message -like "*already exists*") { - Write-Host " āœ“ Role already assigned" -ForegroundColor Green - } else { - Write-Error "Failed to assign Storage Blob Data Owner role: $_" - exit 1 - } -} - -# For Log Analytics backend, also grant Monitoring Metrics Publisher on DCR -if ($Backend -eq 'LogAnalytics') { - Write-Host "`nšŸ“Š Finding Data Collection Rule..." -ForegroundColor Cyan - - # Find DCR in resource group - $dcrs = Get-AzDataCollectionRule -ResourceGroupName $ResourceGroupName -ErrorAction SilentlyContinue - if ($dcrs.Count -eq 0) { - Write-Warning "No Data Collection Rules found. Skipping DCR role assignment." - } else { - $dcr = $dcrs | Select-Object -First 1 - Write-Host " āœ“ Found: $($dcr.Name)" -ForegroundColor Green - - Write-Host "`nšŸ”‘ Granting Monitoring Metrics Publisher role on DCR..." -ForegroundColor Cyan - $monitoringMetricsPublisherRoleId = "3913510d-42f4-4e42-8a64-420c390055eb" - - try { - $existingAssignment = Get-AzRoleAssignment -ObjectId $mi.PrincipalId -RoleDefinitionId $monitoringMetricsPublisherRoleId -Scope $dcr.Id -ErrorAction SilentlyContinue - if ($existingAssignment) { - Write-Host " āœ“ Role already assigned" -ForegroundColor Green - } else { - New-AzRoleAssignment -ObjectId $mi.PrincipalId -RoleDefinitionId $monitoringMetricsPublisherRoleId -Scope $dcr.Id | Out-Null - Write-Host " āœ“ Role assigned successfully" -ForegroundColor Green - } - } catch { - if ($_.Exception.Message -like "*already exists*") { - Write-Host " āœ“ Role already assigned" -ForegroundColor Green - } else { - Write-Error "Failed to assign Monitoring Metrics Publisher role: $_" - exit 1 - } - } - } -} - -Write-Host "`nāœ… Azure role assignments complete!" -ForegroundColor Green -Write-Host "" -Write-Host "āš ļø Don't forget to also run Grant-GraphPermissions.ps1 to grant Microsoft Graph API permissions." -ForegroundColor Yellow -Write-Host " .\scripts\Grant-GraphPermissions.ps1 -ManagedIdentityObjectId `"$($mi.PrincipalId)`"" -ForegroundColor Gray diff --git a/scripts/Grant-GraphPermissions.ps1 b/scripts/Grant-GraphPermissions.ps1 index 60a928a..23d7f8c 100644 --- a/scripts/Grant-GraphPermissions.ps1 +++ b/scripts/Grant-GraphPermissions.ps1 @@ -1,43 +1,54 @@ <# .SYNOPSIS - Grants Microsoft Graph API permissions to the Intune Analytics Function App managed identity. + Grants Microsoft Graph API permissions to a service principal (app registration or managed identity). .DESCRIPTION - This script must be run by a user with one of the following roles: + This script grants the required Graph API permissions for reading Intune data. + Works with both app registrations and managed identities. + + Must be run by a user with one of the following Entra ID roles: - Global Administrator - - Application Administrator + - Application Administrator - Cloud Application Administrator - Run this AFTER deploying the ARM template. - -.PARAMETER FunctionAppName - The name of the deployed Function App (shown in deployment outputs) - -.PARAMETER ResourceGroupName - The resource group containing the Function App +.PARAMETER ServicePrincipalObjectId + The Object ID of the service principal (app registration) or managed identity. + For app registrations: Find this in Entra ID > App registrations > Your app > Overview > "Object ID" (not Application ID) + For managed identities: The Principal ID from the managed identity resource. .EXAMPLE - .\Grant-GraphPermissions.ps1 -FunctionAppName "intune-analytics-fn-abc123" -ResourceGroupName "rg-intune-analytics" + # For app registration: + .\Grant-GraphPermissions.ps1 -ServicePrincipalObjectId "12345678-1234-1234-1234-123456789012" .EXAMPLE - # Or use the managed identity Object ID directly: - .\Grant-GraphPermissions.ps1 -ManagedIdentityObjectId "12345678-1234-1234-1234-123456789012" + # For function app with managed identity: + .\Grant-GraphPermissions.ps1 -FunctionAppName "intune-func-abc123" -ResourceGroupName "rg-intune" #> [CmdletBinding()] param( + [Parameter(Mandatory = $false)] + [string]$ServicePrincipalObjectId, + [Parameter(Mandatory = $false)] [string]$FunctionAppName, [Parameter(Mandatory = $false)] [string]$ResourceGroupName, + # Legacy parameter alias for backwards compatibility [Parameter(Mandatory = $false)] - [string]$ManagedIdentityObjectId + [Alias("ManagedIdentityObjectId")] + [string]$ObjectId ) $ErrorActionPreference = "Stop" +# Handle legacy parameter +if ($ObjectId -and -not $ServicePrincipalObjectId) { + $ServicePrincipalObjectId = $ObjectId +} + # Microsoft Graph App ID (constant) $GraphAppId = "00000003-0000-0000-c000-000000000000" @@ -49,7 +60,7 @@ $RequiredPermissions = @( Description = "Read Intune device information" }, @{ - Name = "DeviceManagementConfiguration.Read.All" + Name = "DeviceManagementConfiguration.Read.All" Id = "dc377aa6-52d8-4e23-b271-2a7ae6b159e6" Description = "Read Intune compliance policies" } @@ -78,25 +89,27 @@ Write-Host "Connected as: $($context.Account.Id)" -ForegroundColor Green Write-Host "Tenant: $($context.Tenant.Id)" -ForegroundColor Green Write-Host "" -# Get the managed identity Object ID -if (-not $ManagedIdentityObjectId) { +# Get the service principal Object ID +if (-not $ServicePrincipalObjectId) { if (-not $FunctionAppName -or -not $ResourceGroupName) { - Write-Error "Please provide either -ManagedIdentityObjectId OR both -FunctionAppName and -ResourceGroupName" + Write-Error "Please provide -ServicePrincipalObjectId OR both -FunctionAppName and -ResourceGroupName" exit 1 } - Write-Host "Getting Function App managed identity..." -ForegroundColor Yellow + Write-Host "Getting Function App identity..." -ForegroundColor Yellow $functionApp = Get-AzWebApp -Name $FunctionAppName -ResourceGroupName $ResourceGroupName - - if (-not $functionApp.Identity.PrincipalId) { - Write-Error "Function App '$FunctionAppName' does not have a system-assigned managed identity enabled" + + if ($functionApp.Identity.PrincipalId) { + $ServicePrincipalObjectId = $functionApp.Identity.PrincipalId + } elseif ($functionApp.Identity.UserAssignedIdentities) { + $ServicePrincipalObjectId = ($functionApp.Identity.UserAssignedIdentities.Values | Select-Object -First 1).PrincipalId + } else { + Write-Error "Function App '$FunctionAppName' does not have a managed identity" exit 1 } - - $ManagedIdentityObjectId = $functionApp.Identity.PrincipalId } -Write-Host "Managed Identity Object ID: $ManagedIdentityObjectId" -ForegroundColor Cyan +Write-Host "Service Principal Object ID: $ServicePrincipalObjectId" -ForegroundColor Cyan Write-Host "" # Get Microsoft Graph service principal @@ -104,7 +117,7 @@ Write-Host "Getting Microsoft Graph service principal..." -ForegroundColor Yello $graphSp = Get-AzADServicePrincipal -ApplicationId $GraphAppId if (-not $graphSp) { - Write-Error "Could not find Microsoft Graph service principal. This should not happen." + Write-Error "Could not find Microsoft Graph service principal." exit 1 } @@ -122,24 +135,24 @@ $errorCount = 0 foreach ($permission in $RequiredPermissions) { Write-Host " $($permission.Name)" -ForegroundColor White -NoNewline Write-Host " - $($permission.Description)" -ForegroundColor Gray - + try { New-AzADServicePrincipalAppRoleAssignment ` - -ServicePrincipalId $ManagedIdentityObjectId ` + -ServicePrincipalId $ServicePrincipalObjectId ` -ResourceId $graphSp.Id ` -AppRoleId $permission.Id ` -ErrorAction Stop | Out-Null - - Write-Host " āœ“ Granted" -ForegroundColor Green + + Write-Host " + Granted" -ForegroundColor Green $successCount++ } catch { if ($_.Exception.Message -like "*already exists*" -or $_.Exception.Message -like "*Permission being assigned already exists*") { - Write-Host " ā—‹ Already assigned" -ForegroundColor DarkGray + Write-Host " = Already assigned" -ForegroundColor DarkGray $skipCount++ } else { - Write-Host " āœ— Failed: $($_.Exception.Message)" -ForegroundColor Red + Write-Host " x Failed: $($_.Exception.Message)" -ForegroundColor Red $errorCount++ } } @@ -155,13 +168,12 @@ Write-Host " Failed: $errorCount" -ForegroundColor $(if ($errorCount -gt 0) { Write-Host "" if ($errorCount -eq 0) { - Write-Host "āœ“ Permissions configured successfully!" -ForegroundColor Green + Write-Host "Permissions configured successfully!" -ForegroundColor Green Write-Host "" - Write-Host "The Function App can now access Microsoft Graph to read Intune data." -ForegroundColor Gray - Write-Host "Data collection will begin on the next timer trigger (every 6 hours)." -ForegroundColor Gray + Write-Host "The app can now access Microsoft Graph to read Intune data." -ForegroundColor Gray } else { - Write-Host "āœ— Some permissions could not be granted." -ForegroundColor Red + Write-Host "Some permissions could not be granted." -ForegroundColor Red Write-Host "" Write-Host "Ensure you have one of these Entra ID roles:" -ForegroundColor Yellow Write-Host " - Global Administrator" -ForegroundColor White diff --git a/shared/__init__.py b/shared/__init__.py index 38e73a6..794cdf4 100644 --- a/shared/__init__.py +++ b/shared/__init__.py @@ -7,12 +7,13 @@ import asyncio from datetime import datetime, timezone from dataclasses import dataclass -from typing import Optional, Protocol +from typing import Optional from abc import ABC, abstractmethod +from io import StringIO -from azure.identity import DefaultAzureCredential, ManagedIdentityCredential -from azure.kusto.data import KustoClient, KustoConnectionStringBuilder, DataFormat -from azure.kusto.ingest import QueuedIngestClient, IngestionProperties +from azure.identity import DefaultAzureCredential, ClientSecretCredential +from azure.kusto.data import KustoConnectionStringBuilder +from azure.kusto.ingest import QueuedIngestClient, IngestionProperties, DataFormat from azure.monitor.ingestion import LogsIngestionClient from msgraph_beta import GraphServiceClient @@ -35,10 +36,6 @@ class Config: log_analytics_dcr_id: str = os.environ.get('LOG_ANALYTICS_DCR_ID', '') # Data Collection Rule ID log_analytics_workspace_id: str = os.environ.get('LOG_ANALYTICS_WORKSPACE_ID', '') - tenant_id: str = os.environ.get('TENANT_ID', '') - dry_run: bool = False - output_path: str = '' - @classmethod def from_env(cls) -> 'Config': return cls() @@ -74,8 +71,7 @@ def __init__(self, config: Config): def _get_ingest_client(self) -> QueuedIngestClient: if not self._ingest_client: - # Use managed identity in Azure, DefaultAzureCredential locally - credential = ManagedIdentityCredential() if os.environ.get('WEBSITE_INSTANCE_ID') else DefaultAzureCredential() + credential = get_credential() ingest_uri = self.config.adx_cluster.replace('https://', 'https://ingest-') kcsb = KustoConnectionStringBuilder.with_azure_token_credential(ingest_uri, credential) self._ingest_client = QueuedIngestClient(kcsb) @@ -91,8 +87,6 @@ def ingest(self, table: str, data: list[dict]) -> int: # Convert to JSONL for ingestion jsonl = '\n'.join(json.dumps(row, default=str) for row in data) - - from io import StringIO client.ingest_from_stream(StringIO(jsonl), props) return len(data) @@ -106,7 +100,7 @@ def __init__(self, config: Config): def _get_client(self) -> LogsIngestionClient: if not self._client: - credential = ManagedIdentityCredential() if os.environ.get('WEBSITE_INSTANCE_ID') else DefaultAzureCredential() + credential = get_credential() self._client = LogsIngestionClient(endpoint=self.config.log_analytics_dce, credential=credential) return self._client @@ -143,9 +137,25 @@ def get_ingestion_client(config: Config) -> IngestionClient: return ADXClient(config) +def get_credential(): + """Get Azure credential - supports client secret or managed identity""" + # If client secret is configured, use it (for app registration auth) + client_id = os.environ.get('AZURE_CLIENT_ID') + client_secret = os.environ.get('AZURE_CLIENT_SECRET') + tenant_id = os.environ.get('AZURE_TENANT_ID') or os.environ.get('TENANT_ID') + + if client_id and client_secret and tenant_id: + logging.info("Using client secret authentication") + return ClientSecretCredential(tenant_id=tenant_id, client_id=client_id, client_secret=client_secret) + + # Otherwise use DefaultAzureCredential (managed identity in Azure, Azure CLI locally) + logging.info("Using DefaultAzureCredential") + return DefaultAzureCredential() + + def get_graph_client() -> GraphServiceClient: - """Get authenticated Graph client using managed identity""" - credential = ManagedIdentityCredential() if os.environ.get('WEBSITE_INSTANCE_ID') else DefaultAzureCredential() + """Get authenticated Graph client""" + credential = get_credential() scopes = ['https://graph.microsoft.com/.default'] return GraphServiceClient(credentials=credential, scopes=scopes)