This file provides guidance to Claude Code when working with the Infrastructure project.
The Infrastructure project contains Azure Bicep templates for deploying the Data Model Viewer website to Azure App Service. It provisions all necessary cloud resources and configurations.
- Azure Bicep - Infrastructure as Code (IaC)
- Azure App Service - Web hosting platform
- Azure App Service Plan - Compute resources
- System Assigned Managed Identity - Authentication for Azure services
Infrastructure/
└── main.bicep # Main Bicep template
-
App Service Plan (
asp-{solutionId})- SKU: F1 (Free tier) by default
- Platform: Linux
- Purpose: Compute capacity for web app
-
Web App (
wa-{solutionId})- Runtime: Node.js 24 LTS
- HTTPS Only: Enabled
- System Assigned Managed Identity: Enabled
- Environment Variables: Configured for Website project
-
Managed Identity
- Type: System Assigned
- Purpose: Authenticate to Dataverse and Azure DevOps without credentials
- Permissions: Must be configured manually after deployment
@description('Unique identifier for naming resources')
param solutionId string
@description('Password for website login')
@secure()
param websitePassword string
@description('Secret key for session encryption')
@secure()
param sessionSecret string
@description('Azure DevOps organization URL')
param adoOrganizationUrl string = ''
@description('Azure DevOps project name')
param adoProjectName string = ''
@description('Azure DevOps repository name for diagram storage')
param adoRepositoryName string = ''
@description('Enable EntraID authentication via Easy Auth')
param enableEntraIdAuth bool = false
@description('Azure AD App Registration Client ID')
param entraIdClientId string = ''
@description('Azure AD Tenant ID (defaults to subscription tenant)')
param entraIdTenantId string = subscription().tenantId
@description('Comma-separated list of Azure AD Group Object IDs allowed to access (empty = all tenant users)')
param entraIdAllowedGroups string = ''
@description('Disable password authentication (EntraID only)')
param disablePasswordAuth bool = false- App Service Plan:
asp-{solutionId} - Web App:
wa-{solutionId} - Full URL:
https://wa-{solutionId}.azurewebsites.net/
Important: solutionId must be globally unique across all Azure customers.
The template configures these environment variables for the Website:
| Variable | Source | Purpose |
|---|---|---|
WebsitePassword |
Parameter | User login password |
WebsiteSessionSecret |
Parameter | JWT encryption key |
WEBSITE_NODE_DEFAULT_VERSION |
Template | Node.js version hint |
ADO_ORGANIZATION_URL |
Parameter | Azure DevOps org URL |
ADO_PROJECT_NAME |
Parameter | ADO project name |
ADO_REPOSITORY_NAME |
Parameter | Diagram storage repo |
ENABLE_ENTRAID_AUTH |
Parameter | Enable EntraID auth |
ENTRAID_ALLOWED_GROUPS |
Parameter | Group-based access control |
DISABLE_PASSWORD_AUTH |
Parameter | Disable password login |
- Azure subscription with appropriate permissions
- Azure CLI or Azure PowerShell
- Resource group (can be created by template)
- Globally unique
solutionIdvalue
# Login to Azure
az login
# Create resource group (if needed)
az group create --name rg-datamodelviewer --location westeurope
# Deploy Bicep template
az deployment group create \
--resource-group rg-datamodelviewer \
--template-file main.bicep \
--parameters solutionId=myorg-dmv \
websitePassword='SecurePassword123!' \
sessionSecret='<32-byte-random-string>' \
adoOrganizationUrl='https://dev.azure.com/myorg' \
adoProjectName='MyProject' \
adoRepositoryName='DataModelViewer'The Azure Pipeline (azure-pipelines-deploy-jobs.yml) deploys using:
- task: AzureResourceManagerTemplateDeployment@3
inputs:
deploymentScope: 'Resource Group'
azureResourceManagerConnection: $(AzureServiceConnectionName)
subscriptionId: $(AzureSubscriptionId)
action: 'Create Or Update Resource Group'
resourceGroupName: $(AzureResourceGroupName)
location: $(AzureLocation)
templateLocation: 'Linked artifact'
csmFile: 'Infrastructure/main.bicep'
overrideParameters: |
-solutionId $(WebsiteName)
-websitePassword $(WebsitePassword)
-sessionSecret $(WebsiteSessionSecret)
-adoOrganizationUrl $(ADO_ORGANIZATION_URL)
-adoProjectName $(ADO_PROJECT_NAME)
-adoRepositoryName $(AdoRepositoryName)The application supports optional Microsoft EntraID (Azure AD) authentication using OpenID Connect (via NextAuth.js). This provides enterprise single sign-on (SSO) with your organization's Microsoft accounts.
Important: This implementation uses standard OpenID Connect flow, NOT Azure App Service Easy Auth. Users can always access the login page - authentication only occurs when they click "Sign in with Microsoft".
Three authentication modes are supported:
- Password Only (default): Traditional password-based login
- EntraID Only: Microsoft SSO authentication via OpenID Connect, password login disabled
- Dual Mode: Users can choose between password or Microsoft SSO
Before enabling EntraID authentication:
- Azure AD App Registration
- User access to Azure AD tenant
- Optional: Azure AD security groups for access control
- Navigate to Azure Portal → Azure Active Directory → App registrations
- Click New registration
- Configure:
- Name:
Data Model Viewer - {environment}(e.g.,Data Model Viewer - Production) - Supported account types:
Accounts in this organizational directory only (Single tenant) - Redirect URI:
- Platform:
Web - URI:
https://wa-{solutionId}.azurewebsites.net/api/auth/callback/microsoft-entra-id - Replace
{solutionId}with your actual solution ID
- Platform:
- Name:
- Click Register
- Note the Application (client) ID and Directory (tenant) ID from the Overview page
CRITICAL: Azure App Service Easy Auth requires a client secret to avoid using deprecated implicit grant flow.
- In your App Registration, go to Certificates & secrets
- Click Client secrets → New client secret
- Enter:
- Description:
App Service SSO - Expires: Choose appropriate duration (e.g., 24 months)
- Description:
- Click Add
- IMPORTANT: Copy the Value (not the Secret ID) immediately - it won't be shown again
- Save this value securely - you'll use it in the deployment
Alternative (Preview): Azure supports using a managed identity with federated credentials instead of a client secret. This approach is currently in preview and requires additional setup. For production deployments, the client secret approach is recommended. See Microsoft's documentation on using a managed identity instead of a secret if interested.
- In your App Registration, go to API permissions
- Click Add a permission → Microsoft Graph → Delegated permissions
- Add these permissions:
User.Read(required - basic user profile)Group.Read.All(optional - required for group-based access control)
- Click Add permissions
- Grant admin consent if required by your organization
To enable group-based access control:
- Go to Token configuration in your App Registration
- Click Add groups claim
- Select Security groups
- Check both ID and Access tokens
- Click Add
If restricting access to specific groups:
- Navigate to Azure Active Directory → Groups
- Find the group(s) that should have access
- Click on each group and copy the Object ID
- Prepare comma-separated list:
abc123-...,def456-...,ghi789-...
az deployment group create \
--resource-group rg-datamodelviewer \
--template-file main.bicep \
--parameters solutionId=myorg-dmv \
websitePassword='SecurePassword123!' \
sessionSecret='<32-byte-random-string>' \
enableEntraIdAuth=true \
entraIdClientId='<your-client-id>' \
entraIdClientSecret='<your-client-secret>' \
entraIdTenantId='<your-tenant-id>' \
entraIdAllowedGroups='<group-id-1>,<group-id-2>' \
disablePasswordAuth=false \
adoOrganizationUrl='https://dev.azure.com/myorg' \
adoProjectName='MyProject' \
adoRepositoryName='DataModelViewer'az deployment group create \
--resource-group rg-datamodelviewer \
--template-file main.bicep \
--parameters @previous-parameters.json \
enableEntraIdAuth=true \
entraIdClientId='<your-client-id>' \
entraIdClientSecret='<your-client-secret>' \
entraIdTenantId='<your-tenant-id>'| Parameter | Required | Default | Description |
|---|---|---|---|
enableEntraIdAuth |
No | false |
Enables EntraID authentication via Easy Auth |
entraIdClientId |
Yes if enabled | '' |
Application (client) ID from App Registration |
entraIdClientSecret |
Yes if enabled | '' |
Client secret value from App Registration |
entraIdTenantId |
No | Subscription tenant | Directory (tenant) ID for your Azure AD |
entraIdAllowedGroups |
No | '' |
Comma-separated group Object IDs. Empty = all tenant users |
disablePasswordAuth |
No | false |
Set to true for EntraID-only mode |
--parameters enableEntraIdAuth=true \
entraIdClientId='abc123...' \
entraIdClientSecret='secret123...' \
disablePasswordAuth=false- Users see both "Sign in with Microsoft" and password form
- Existing password auth continues to work
- Ideal for gradual migration
--parameters enableEntraIdAuth=true \
entraIdClientId='abc123...' \
entraIdClientSecret='secret123...' \
disablePasswordAuth=true- Only "Sign in with Microsoft" button shown
- Password login disabled
- Full enterprise SSO
--parameters enableEntraIdAuth=true \
entraIdClientId='abc123...' \
entraIdClientSecret='secret123...' \
entraIdAllowedGroups='group-id-1,group-id-2'- Only users in specified security groups can access
- Returns 403 Forbidden for unauthorized users
- User Access: User navigates to
https://wa-{solutionId}.azurewebsites.net/ - Easy Auth Intercepts: App Service Easy Auth detects unauthenticated request
- Redirect to Microsoft: User redirected to
login.microsoftonline.com - User Signs In: User authenticates with Microsoft account
- Token Exchange: Microsoft returns ID token to Easy Auth
- Header Injection: Easy Auth validates token and injects
X-MS-CLIENT-PRINCIPALheader - Application Access: Middleware parses header, creates session, grants access
Problem: No client secret configured - Easy Auth is falling back to deprecated implicit grant flow
Solution:
- Create a client secret in your App Registration:
- Go to Azure Portal → Azure Active Directory → App registrations
- Select your Data Model Viewer app registration
- Go to Certificates & secrets → Client secrets → New client secret
- Copy the secret Value (not Secret ID)
- Redeploy with the client secret parameter:
az deployment group create \ --resource-group rg-datamodelviewer \ --template-file main.bicep \ --parameters @previous-parameters.json \ entraIdClientSecret='<paste-your-client-secret-here>' - Wait 2-3 minutes for App Service to restart
- Try logging in again
Note: Easy Auth requires a client secret to avoid using the deprecated OAuth 2.0 implicit grant flow
Problem: App Registration redirect URI doesn't match deployed URL
Solution:
- Check App Registration → Authentication → Redirect URIs
- Ensure it matches:
https://wa-{solutionId}.azurewebsites.net/.auth/login/aad/callback - Verify HTTPS (not HTTP)
- No trailing slash
Problem: User's account is not in the specified tenant
Solution:
- Verify user belongs to correct Azure AD tenant
- Check App Registration is "Single tenant" type
- Ensure user account is not external/guest (or add multi-tenant support)
Problem: User not in allowed security groups
Solution:
- Check
entraIdAllowedGroupsparameter includes user's group - Verify group claim is configured in token configuration
- Check API permission
Group.Read.Allis granted - Wait 5-10 minutes for group membership cache to refresh
Expected Behavior: Easy Auth only works on Azure App Service
Solution:
- Use password authentication for local development
- Set
ENABLE_ENTRAID_AUTH=falsein.env.local - Test EntraID in deployed dev environment
Problem: Looking for wrong object
Solution:
- Managed Identity is for backend services (Dataverse, ADO)
- EntraID/Easy Auth is for user authentication
- These are separate authentication mechanisms
- Don't add users to Managed Identity
Important: Azure App Service requires explicit startup command for Next.js standalone mode.
- Navigate to Azure Portal → App Service → Configuration → General settings
- Set Startup Command to:
node server.js - Save and restart app service
Without this, the app will not start correctly.
The deployed Managed Identity needs two sets of permissions:
- Navigate to Power Platform Admin Center
- Select your Dataverse environment
- Go to Settings → Users + permissions → Users
- Add the Managed Identity (
wa-{solutionId}) as a user - Assign Environment Maker security role (or custom role with read access)
- Navigate to Azure DevOps → Organization Settings → Users
- Click "Add users"
- Search for
wa-{solutionId}(the managed identity) - Select the service principal from results
- Grant Basic access level
- Add to appropriate project
- Uncheck "Send email invites"
- Navigate to Project Settings → Repositories → DataModelViewer repo → Security
- Add Managed Identity with Contributor permissions (or least-privilege custom role with read/write)
Note: Contributor grants more permissions than strictly necessary. Consider creating a custom role with only:
- Read repository
- Create branch
- Commit changes
- Create pull request (if using PR workflow)
# Get web app URL
az webapp show --name wa-{solutionId} --resource-group rg-datamodelviewer --query "defaultHostName" -o tsv
# Check deployment logs
az webapp log tail --name wa-{solutionId} --resource-group rg-datamodelviewer
# Verify managed identity
az webapp identity show --name wa-{solutionId} --resource-group rg-datamodelviewerDefault: F1 (Free tier) - limited compute, no custom domains, no scaling
Upgrade for production:
resource appServicePlan 'Microsoft.Web/serverfarms@2021-02-01' = {
name: 'asp-${solutionId}'
location: location
sku: {
name: 'B1' // Basic tier
tier: 'Basic'
}
properties: {
reserved: true
}
}SKU Options:
F1- Free (shared compute, 60 min/day)B1- Basic (dedicated, 1 core, 1.75GB RAM)S1- Standard (auto-scale, custom domains, SSL)P1V2- Premium (better performance, more features)
- Update Bicep to use S1 or higher SKU
- Add custom hostname binding:
resource hostname 'Microsoft.Web/sites/hostNameBindings@2021-02-01' = {
parent: webApp
name: 'dmv.mycompany.com'
properties: {
siteName: webApp.name
hostNameType: 'Verified'
}
}- Configure DNS CNAME record
- Add SSL certificate
resource appInsights 'Microsoft.Insights/components@2020-02-02' = {
name: 'ai-${solutionId}'
location: location
kind: 'web'
properties: {
Application_Type: 'Node.JS'
}
}
// Add to web app properties
{
name: 'APPLICATIONINSIGHTS_CONNECTION_STRING'
value: appInsights.properties.ConnectionString
}resource keyVault 'Microsoft.KeyVault/vaults@2021-06-01-preview' = {
name: 'kv-${solutionId}'
location: location
properties: {
tenantId: subscription().tenantId
sku: { family: 'A', name: 'standard' }
accessPolicies: [
{
tenantId: subscription().tenantId
objectId: webApp.identity.principalId
permissions: {
secrets: ['get', 'list']
}
}
]
}
}
// Reference in web app
{
name: 'WebsitePassword'
value: '@Microsoft.KeyVault(VaultName=kv-${solutionId};SecretName=WebsitePassword)'
}Set these in Azure DevOps Variable Group (typically named DataModel):
| Variable | Description | Secret |
|---|---|---|
AzureServiceConnectionName |
ARM service connection name | No |
AzureLocation |
Azure region (e.g., westeurope) |
No |
AzureResourceGroupName |
Resource group name | No |
WebsiteName |
Unique site identifier (solutionId) | No |
WebsitePassword |
Login password | Yes |
WebsiteSessionSecret |
JWT encryption key (32 chars) | Yes |
ADO_ORGANIZATION_URL |
ADO org URL | No |
ADO_PROJECT_NAME |
ADO project name | No |
AdoRepositoryName |
Diagram storage repo | No |
- Build Stage: Website built into
.next/standalonefolder - Infrastructure Stage: Bicep template deploys/updates resources
- Deploy Stage: Website files uploaded to App Service
- Post-Deploy: Manual startup command configuration (first time only)
Problem: solutionId is not globally unique
Solution:
- Choose a different
solutionIdvalue - Check existing App Services:
az webapp list --query "[].name" - Use format like:
{company}-{environment}-dmv(e.g.,contoso-prod-dmv)
Problem: Startup command not configured or incorrect
Solution:
- Check Configuration → General settings → Startup Command
- Should be:
node server.js - Check logs:
az webapp log tail --name wa-{solutionId} --resource-group {rg}
Problem: MI doesn't have proper permissions
Solution:
- Verify MI exists:
az webapp identity show - Check Dataverse role assignments in Power Platform
- Check ADO permissions in Organization Settings → Users
- Wait 5-10 minutes for permission propagation
Problem: Website can't read configuration
Solution:
- Check App Service → Configuration → Application settings
- Verify all required variables present
- Restart web app after adding variables
- Check for typos in variable names
# Check syntax
az bicep build --file main.bicep
# Validate against Azure
az deployment group validate \
--resource-group rg-datamodelviewer \
--template-file main.bicep \
--parameters solutionId=test-dmv websitePassword=Test123! sessionSecret=test123# See what would change without deploying
az deployment group what-if \
--resource-group rg-datamodelviewer \
--template-file main.bicep \
--parameters solutionId=myorg-dmv websitePassword=Pass123! sessionSecret=secret123- F1 Free Tier: $0/month (1 app per subscription)
- B1 Basic: ~$13/month
- S1 Standard: ~$70/month
- P1V2 Premium: ~$150/month
Plus data transfer costs (minimal for typical usage).
Always mark sensitive parameters with @secure() decorator:
@secure()
param websitePassword stringThis prevents values from appearing in logs or deployment history.
Template enforces HTTPS:
properties: {
httpsOnly: true
}Never disable this in production.
Always prefer Managed Identity over connection strings/PAT tokens for:
- Azure service authentication
- Azure DevOps API access
- Dataverse connectivity
Managed Identity eliminates secret management and rotation.
For production, consider:
- Private Endpoints: Connect App Service to VNet
- Access Restrictions: Limit inbound traffic by IP
- VNet Integration: Access private Dataverse instances
solutionIdparameter must be globally unique- Always set startup command to
node server.jsafter first deployment - Managed Identity requires manual permission configuration
- Free tier (F1) has limitations - upgrade for production
- Template deploys to same region as resource group
- Environment variables are configured in template - no manual portal configuration needed
- Website deployment happens separately from infrastructure deployment
- Test with
what-ifcommand before production changes