SC-300 Phase 2 Part B: Converting My Microsoft Entra ID Lab into Terraform
📁 GitHub Repository: lwc-wk/enterprise-azure-lab1
The Goal of This Lab
This post is Part B of my SC-300 Phase 2 lab.
In Part A, I built the MVP identity flow mostly through the Azure Portal. That gave me screenshots, validation, and a working Microsoft Entra ID authentication path.
Part B was different.
The goal was not to build everything from scratch with Terraform.
The goal was to safely convert an already-working Portal-built environment into Infrastructure as Code.
In other words, this was a brownfield IaC adoption exercise.
The working environment already had:
Microsoft Entra groups
App Registration
Enterprise Application
App Service Authentication / Easy Auth
App Service Managed Identity
Azure RBAC assignments
Key Vault
Key Vault secret
Key Vault reference in App Service
The Terraform goal was:
Existing Portal MVP
↓
Import real cloud resources into Terraform state
↓
Prevent Terraform from deleting working Portal settings
↓
Add missing IaC-managed identity features
↓
Fix drift carefully
↓
Apply only after the plan was understood
This was the most important lesson from this part of the lab:
Brownfield Terraform is not about writing code first.
It is about making Terraform state match reality before automation becomes safe.
Architecture Scope for Part B
The identity flow of the lab was:
User
↓
Azure App Service
↓
App Service Authentication / Easy Auth
↓
Microsoft Entra ID
↓
Enterprise Application Assignment
↓
Conditional Access
↓
Authenticated Dashboard
This Terraform conversion focused on Steps 41–56 of my Phase 2 MVP.
The main resources were:
Microsoft Entra groups
App Registration
Enterprise Application / Service Principal
App Roles
Azure RBAC role assignments
App Service Managed Identity
Key Vault
Key Vault Secret
Key Vault Secrets User role assignment
One important mental model:
App Roles
= application-level authorization
= what the user can do inside the app
Azure RBAC
= Azure resource authorization
= what a user/group/service identity can do to Azure resources
These two are related, but they are not the same thing.
Brownfield IaC Rule: Do Not Blindly Apply
Because the resources already existed, I could not just write Terraform code and run:
terraform apply
That would be dangerous.
The safe workflow was:
terraform fmt
terraform validate
terraform plan \
-var-file="../env/dev.tfvars" \
-var-file="../env/local.identity.tfvars" \
-var-file="../env/local.secrets.tfvars" \
-var-file="../env/local.rbac.tfvars"
Before applying, I checked whether Terraform wanted to remove anything important, especially:
auth_settings_v2
DEMO_APP_SECRET
MICROSOFT_PROVIDER_AUTHENTICATION_SECRET
WEBSITE_AUTH_AAD_ALLOWED_TENANTS
Existing Microsoft Entra groups
Existing RBAC role assignments
If the plan tried to remove working authentication settings, I stopped.
That happened multiple times during the conversion.
Step 43–44: Import Existing Microsoft Entra Groups
The lab used four Microsoft Entra security groups:
lab-readers
lab-operators
lab-security-reviewers
lab-owners
These groups already existed because I created them during the Portal MVP.
The Terraform code looked like this:
resource "azuread_group" "lab_readers" {
display_name = "lab-readers"
security_enabled = true
description = "SC-300 lab group for users who can sign in and view non-sensitive dashboard content only."
}
resource "azuread_group" "lab_operators" {
display_name = "lab-operators"
security_enabled = true
description = "SC-300 lab group for users who can view operational dashboard sections and perform limited safe operations."
}
resource "azuread_group" "lab_security_reviewers" {
display_name = "lab-security-reviewers"
security_enabled = true
description = "SC-300 lab group for users who can review logs, diagnostics, NSG, RBAC, and security evidence."
}
resource "azuread_group" "lab_owners" {
display_name = "lab-owners"
security_enabled = true
description = "SC-300 lab group for users who have full lab dashboard visibility and roadmap ownership."
}
The first Terraform plan wanted to create new groups.
That was not safe, because the groups already existed.
So I queried the existing group object IDs:
az ad group show --group "lab-readers" --query id -o tsv
az ad group show --group "lab-operators" --query id -o tsv
az ad group show --group "lab-security-reviewers" --query id -o tsv
az ad group show --group "lab-owners" --query id -o tsv
The existing group object IDs were:
lab-readers 3redacted7
lab-operators 3redacted7
lab-security-reviewers bredactede
lab-owners bredacted2
Then I imported them into Terraform state:
terraform import \
-var-file="../env/dev.tfvars" \
-var-file="../env/local.identity.tfvars" \
-var-file="../env/local.secrets.tfvars" \
-var-file="../env/local.rbac.tfvars" \
azuread_group.lab_readers \
"/groups/3redacted7"
terraform import \
-var-file="../env/dev.tfvars" \
-var-file="../env/local.identity.tfvars" \
-var-file="../env/local.secrets.tfvars" \
-var-file="../env/local.rbac.tfvars" \
azuread_group.lab_operators \
"/groups/3redacted7"
terraform import \
-var-file="../env/dev.tfvars" \
-var-file="../env/local.identity.tfvars" \
-var-file="../env/local.secrets.tfvars" \
-var-file="../env/local.rbac.tfvars" \
azuread_group.lab_security_reviewers \
"/groups/bredactede"
terraform import \
-var-file="../env/dev.tfvars" \
-var-file="../env/local.identity.tfvars" \
-var-file="../env/local.secrets.tfvars" \
-var-file="../env/local.rbac.tfvars" \
azuread_group.lab_owners \
"/groups/bredacted2"
Step 44: Fix Placeholder User Object IDs
My env/local.identity.tfvars initially had placeholder IDs:
lab_reader_user_object_ids = [
"00000000-0000-0000-0000-000000000001"
]
lab_operator_user_object_ids = [
"00000000-0000-0000-0000-000000000002"
]
lab_security_reviewer_user_object_ids = [
"00000000-0000-0000-0000-000000000003"
]
lab_owner_user_object_ids = [
"00000000-0000-0000-0000-000000000004"
]
This was dangerous.
Terraform would try to create group memberships for fake users.
So I replaced the placeholder values with empty lists:
lab_reader_user_object_ids = []
lab_operator_user_object_ids = []
lab_security_reviewer_user_object_ids = []
lab_owner_user_object_ids = []
This kept the group membership resources safe until I was ready to use real user object IDs.
The lesson:
Use empty lists when identity data is not ready.
Do not let fake object IDs reach terraform apply.
Step 46: Import the Existing App Registration
The App Registration was already created during the Portal MVP.
The application was:
enterprise-azure-lab-app
I imported it into Terraform:
terraform import \
-var-file="../env/dev.tfvars" \
-var-file="../env/local.identity.tfvars" \
-var-file="../env/local.secrets.tfvars" \
azuread_application.enterprise_lab_app \
"/applications/2redacteda"
One important Terraform provider detail:
The import ID was not just the GUID.
The provider expected:
/applications/<application-object-id>
The App Registration client ID was:
9redacted0
But the Terraform import used the application object ID:
2redacteda
That distinction matters.
Client ID / Application ID
= used by apps and authentication flows
Object ID
= used to identify the directory object itself
Step 47: Import the Enterprise Application / Service Principal
The App Registration has a corresponding Enterprise Application in the tenant.
In Terraform, this is represented as:
resource "azuread_service_principal" "enterprise_lab_app" {
client_id = azuread_application.enterprise_lab_app.client_id
app_role_assignment_required = true
lifecycle {
prevent_destroy = true
ignore_changes = [
tags,
]
}
}
The service principal object ID was:
dredacted4
The import command was:
terraform import \
-var-file="../env/dev.tfvars" \
-var-file="../env/local.identity.tfvars" \
-var-file="../env/local.secrets.tfvars" \
azuread_service_principal.enterprise_lab_app \
"/servicePrincipals/dredacted4"
This resource controlled:
Enterprise Application behavior
Assignment required setting
App role assignment target
Tenant-side service principal state
Conceptually:
App Registration
= application definition
Enterprise Application / Service Principal
= tenant instance used for assignments and sign-in
Step 46.5: API Permissions
For this MVP, I only needed Microsoft Graph delegated User.Read.
Terraform code:
required_resource_access {
resource_app_id = "00000003-0000-0000-c000-000000000000"
resource_access {
id = "eredactedd"
type = "Scope"
}
}
The important lesson:
required_resource_access
= request the permission
admin consent
= approve the permission
Terraform declaring the permission does not automatically mean tenant-wide admin consent has been granted.
Step 48: Protect Portal-Created App Service Authentication
This was the most important safety step.
The App Service Authentication / Easy Auth configuration was originally created in the Portal.
Terraform did not yet fully define these settings:
auth_settings_v2
MICROSOFT_PROVIDER_AUTHENTICATION_SECRET
WEBSITE_AUTH_AAD_ALLOWED_TENANTS
DEMO_APP_SECRET
sticky_settings
When I first ran terraform plan, Terraform wanted to remove them.
That would have broken:
Microsoft Entra login
App Service Easy Auth
Key Vault reference
Authenticated dashboard access
To prevent that, I added this temporary lifecycle rule to the Web App resource:
resource "azurerm_linux_web_app" "main" {
name = "app-${local.resource_name_prefix}-${random_string.suffix.result}"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
service_plan_id = azurerm_service_plan.main.id
https_only = true
site_config {
always_on = false
app_command_line = "npm start"
minimum_tls_version = "1.2"
ftps_state = "FtpsOnly"
application_stack {
node_version = "24-lts"
}
}
identity {
type = "SystemAssigned"
}
app_settings = {
"APP_ENVIRONMENT" = var.environment
}
lifecycle {
ignore_changes = [
app_settings,
auth_settings_v2,
sticky_settings,
]
}
tags = local.common_tags
}
This is not the perfect final state.
It is a safe migration state.
The purpose was:
Do not let Terraform delete working Portal-built authentication settings.
Later, the Easy Auth settings can be fully codified and this lifecycle ignore rule can be removed.
Step 50: Create App Roles with Terraform
The Portal MVP had Enterprise App assignments, but they were still using Default Access.
That was enough to control whether users could sign in, but it did not provide meaningful application roles.
I wanted real App Roles:
LabReader
LabOperator
LabSecurityReviewer
LabOwner
Terraform code:
resource "random_uuid" "app_role_lab_reader" {}
resource "random_uuid" "app_role_lab_operator" {}
resource "random_uuid" "app_role_lab_security_reviewer" {}
resource "random_uuid" "app_role_lab_owner" {}
Then inside the App Registration:
resource "azuread_application" "enterprise_lab_app" {
display_name = "enterprise-azure-lab-app"
sign_in_audience = "AzureADMyOrg"
required_resource_access {
resource_app_id = "00000003-0000-0000-c000-000000000000"
resource_access {
id = "eredactedd"
type = "Scope"
}
}
web {
redirect_uris = [
"https://app-infra-dev-azlab2-redacted.net/.auth/login/aad/callback"
]
implicit_grant {
access_token_issuance_enabled = false
id_token_issuance_enabled = true
}
}
app_role {
allowed_member_types = ["User"]
description = "Read-only dashboard access for architecture and non-sensitive evidence."
display_name = "LabReader"
enabled = true
id = random_uuid.app_role_lab_reader.result
value = "LabReader"
}
app_role {
allowed_member_types = ["User"]
description = "Operator dashboard access for App Service, deployment, and limited operations evidence."
display_name = "LabOperator"
enabled = true
id = random_uuid.app_role_lab_operator.result
value = "LabOperator"
}
app_role {
allowed_member_types = ["User"]
description = "Security reviewer access for logs, diagnostics, RBAC, and security evidence."
display_name = "LabSecurityReviewer"
enabled = true
id = random_uuid.app_role_lab_security_reviewer.result
value = "LabSecurityReviewer"
}
app_role {
allowed_member_types = ["User"]
description = "Owner access for full dashboard, cost, roadmap, and administrative evidence."
display_name = "LabOwner"
enabled = true
id = random_uuid.app_role_lab_owner.result
value = "LabOwner"
}
lifecycle {
prevent_destroy = true
}
}
After applying, I verified the App Roles:
az ad app show \
--id 9redacted0 \
--query "appRoles[].{displayName:displayName,value:value,id:id,enabled:isEnabled,allowedMemberTypes:allowedMemberTypes}" \
-o table
The result:
DisplayName Value Enabled
------------------- ------------------- ---------
LabReader LabReader True
LabOwner LabOwner True
LabSecurityReviewer LabSecurityReviewer True
LabOperator LabOperator True
This proved that Step 50 was successfully converted into Terraform.
Step 52: Azure RBAC Assignments
App Roles control what users can do inside the application.
Azure RBAC controls what groups can do to Azure resources.
For this lab, I mapped the groups like this:
lab-readers
→ Reader
→ Resource Group
lab-operators
→ Website Contributor
→ App Service only
lab-security-reviewers
→ Monitoring Reader
→ Resource Group
Terraform code:
resource "azurerm_role_assignment" "lab_readers_rg_reader" {
count = var.enable_rbac_assignments ? 1 : 0
scope = azurerm_resource_group.lab.id
role_definition_name = "Reader"
principal_id = azuread_group.lab_readers.object_id
principal_type = "Group"
}
resource "azurerm_role_assignment" "lab_operators_webapp_contributor" {
count = var.enable_rbac_assignments ? 1 : 0
scope = azurerm_linux_web_app.main.id
role_definition_name = "Website Contributor"
principal_id = azuread_group.lab_operators.object_id
principal_type = "Group"
}
resource "azurerm_role_assignment" "lab_security_reviewers_monitoring_reader" {
count = var.enable_rbac_assignments ? 1 : 0
scope = azurerm_resource_group.lab.id
role_definition_name = "Monitoring Reader"
principal_id = azuread_group.lab_security_reviewers.object_id
principal_type = "Group"
}
RBAC Drift During Brownfield Import
Terraform initially showed that two RBAC assignments needed replacement:
lab_readers_rg_reader
principal_id: 1redactede
↓
principal_id: 3redacted7
lab_operators_webapp_contributor
principal_id: 2redacteda
↓
principal_id: 3redacted7
At first, I did not blindly apply.
I checked whether the old principal IDs still existed:
az ad group show \
--group 1redactede \
--query "{displayName:displayName,id:id}" \
-o json
az ad group show \
--group 2redacteda \
--query "{displayName:displayName,id:id}" \
-o json
Azure returned:
Resource does not exist or one of its queried reference-property objects are not present.
That meant the old role assignments were pointing to deleted or stale group principals.
So the replacement was acceptable.
Terraform then recreated the role assignments against the current imported groups.
RBAC Verification
I verified the Resource Group scope:
az role assignment list \
--scope "/subscriptions/0redacted0/resourceGroups/rg-infra-dev-azlab2" \
--query "[?principalId=='3redacted7' || principalId=='bredactede'].{role:roleDefinitionName,principalId:principalId,scope:scope}" \
-o table
Output:
Role PrincipalId Scope
----------------- ------------------------------------ --------------------------------------------------------------------------------------
Monitoring Reader bredactede /subscriptions/0redacted0/resourceGroups/rg-infra-dev-azlab2
Reader 3redacted7 /subscriptions/0redacted0/resourceGroups/rg-infra-dev-azlab2
I also verified the App Service scope:
WEBAPP_ID=$(az webapp show \
--resource-group rg-infra-dev-azlab2 \
--name app-infra-dev-azlab2-zlngem \
--query id \
-o tsv)
az role assignment list \
--scope "$WEBAPP_ID" \
--query "[?principalId=='3redacted7'].{role:roleDefinitionName,principalId:principalId,scope:scope}" \
-o table
Output:
Role PrincipalId Scope
------------------- ------------------------------------ ------------------------------------------------------------------------------------------------------------------------------------------------
Website Contributor 3redacted7 /subscriptions/0redacted0/resourceGroups/rg-infra-dev-azlab2/providers/Microsoft.Web/sites/app-infra-dev-azlab2-zlngem
This confirmed the intended least privilege model:
Readers can read the Resource Group.
Operators can contribute only to the Web App.
Security reviewers can read monitoring data at the Resource Group scope.
Step 55: App Service Managed Identity
The App Service needed a system-assigned managed identity so it could access Azure resources without storing credentials in the application code.
Terraform code:
resource "azurerm_linux_web_app" "main" {
# Existing settings omitted
identity {
type = "SystemAssigned"
}
}
This identity is not the same as user login.
Microsoft Entra login
= authenticates human users into the web app
Managed Identity
= gives the App Service its own Azure identity
In this lab, the App Service managed identity was used to read a Key Vault secret.
Step 56: Key Vault and Secret Access
The Key Vault used Azure RBAC authorization:
data "azurerm_client_config" "current" {}
resource "azurerm_key_vault" "main" {
name = "kv-${local.resource_name_prefix}"
location = azurerm_resource_group.lab.location
resource_group_name = azurerm_resource_group.lab.name
tenant_id = data.azurerm_client_config.current.tenant_id
sku_name = "standard"
rbac_authorization_enabled = true
soft_delete_retention_days = 7
purge_protection_enabled = false
tags = local.common_tags
}
The demo secret:
resource "azurerm_key_vault_secret" "demo_app_secret" {
name = "DemoAppSecret"
value = "This value comes from Key Vault"
key_vault_id = azurerm_key_vault.main.id
tags = local.common_tags
}
Because the secret already existed from the Portal version, I imported it:
SECRET_ID=$(az keyvault secret show \
--vault-name kv-infra-dev-azlab2 \
--name DemoAppSecret \
--query id \
-o tsv)
terraform import \
-var-file="../env/dev.tfvars" \
-var-file="../env/local.identity.tfvars" \
-var-file="../env/local.secrets.tfvars" \
-var-file="../env/local.rbac.tfvars" \
azurerm_key_vault_secret.demo_app_secret \
"$SECRET_ID"
Then I granted the App Service managed identity permission to read secrets:
resource "azurerm_role_assignment" "webapp_key_vault_secrets_user" {
scope = azurerm_key_vault.main.id
role_definition_name = "Key Vault Secrets User"
principal_id = azurerm_linux_web_app.main.identity[0].principal_id
principal_type = "ServicePrincipal"
depends_on = [
azurerm_key_vault.main,
azurerm_linux_web_app.main
]
}
This created the access chain:
App Service Managed Identity
→ Key Vault Secrets User
→ Key Vault
→ DemoAppSecret
Import Mistake: Principal ID vs Role Assignment ID
I initially tried to import the Key Vault role assignment using this value:
3redacted5
That was the App Service managed identity principal ID.
Terraform returned:
could not parse Role Assignment ID
The reason:
principal_id
= who receives the permission
role assignment id
= the Azure resource object representing the permission grant
Terraform needed the full role assignment resource ID.
The corrected command was:
KV_ID=$(az keyvault show \
--name kv-infra-dev-azlab2 \
--resource-group rg-infra-dev-azlab2 \
--query id \
-o tsv)
WEBAPP_PRINCIPAL_ID=$(az webapp identity show \
--resource-group rg-infra-dev-azlab2 \
--name app-infra-dev-azlab2-zlngem \
--query principalId \
-o tsv)
ROLE_ASSIGNMENT_ID=$(az role assignment list \
--assignee "$WEBAPP_PRINCIPAL_ID" \
--scope "$KV_ID" \
--query "[?roleDefinitionName=='Key Vault Secrets User'].id | [0]" \
-o tsv)
terraform import \
-var-file="../env/dev.tfvars" \
-var-file="../env/local.identity.tfvars" \
-var-file="../env/local.secrets.tfvars" \
-var-file="../env/local.rbac.tfvars" \
azurerm_role_assignment.webapp_key_vault_secrets_user \
"$ROLE_ASSIGNMENT_ID"
This was a useful brownfield lesson:
A principal ID identifies the identity.
A role assignment ID identifies the permission grant.
Terraform imports the permission grant, not just the identity.
Final Apply and the Last 409 Conflict
After the imports and lifecycle protection were in place, the Terraform plan became much safer.
The plan mainly wanted to:
Add App Roles
Update Key Vault secret metadata
Replace stale RBAC assignments
Create Monitoring Reader for lab-security-reviewers
I applied the plan.
Most of it succeeded:
random_uuid.app_role_lab_security_reviewer: Creation complete
random_uuid.app_role_lab_reader: Creation complete
random_uuid.app_role_lab_operator: Creation complete
random_uuid.app_role_lab_owner: Creation complete
azuread_application.enterprise_lab_app: Modifications complete
azurerm_role_assignment.lab_operators_webapp_contributor: Creation complete
azurerm_role_assignment.lab_readers_rg_reader: Creation complete
azurerm_key_vault_secret.demo_app_secret: Modifications complete
Then Terraform failed on one resource:
Error: unexpected status 409 (409 Conflict) with error: RoleAssignmentExists:
The role assignment already exists.
The failing resource was:
azurerm_role_assignment.lab_security_reviewers_monitoring_reader[0]
The meaning was:
Azure already had this Monitoring Reader role assignment.
Terraform code wanted it.
Terraform state did not know it yet.
This is a classic brownfield IaC state problem.
The fix is to import the existing role assignment:
RG_ID=$(az group show \
--name rg-infra-dev-azlab2 \
--query id \
-o tsv)
SECURITY_REVIEWERS_GROUP_ID="bredactede"
MONITORING_READER_ASSIGNMENT_ID=$(az role assignment list \
--assignee "$SECURITY_REVIEWERS_GROUP_ID" \
--scope "$RG_ID" \
--query "[?roleDefinitionName=='Monitoring Reader'].id | [0]" \
-o tsv)
terraform import \
-var-file="../env/dev.tfvars" \
-var-file="../env/local.identity.tfvars" \
-var-file="../env/local.secrets.tfvars" \
-var-file="../env/local.rbac.tfvars" \
'azurerm_role_assignment.lab_security_reviewers_monitoring_reader[0]' \
"$MONITORING_READER_ASSIGNMENT_ID"
The square brackets matter:
The Terraform resource uses count.
So the import address must include:
[0]
I wrapped the address in single quotes so the shell would not misread the brackets.
Final Verification
After the apply, I verified the App Roles:
az ad app show \
--id 9redacted0 \
--query "appRoles[].{displayName:displayName,value:value,id:id,enabled:isEnabled,allowedMemberTypes:allowedMemberTypes}" \
-o table
Output:
DisplayName Value Enabled
------------------- ------------------- ---------
LabReader LabReader True
LabOwner LabOwner True
LabSecurityReviewer LabSecurityReviewer True
LabOperator LabOperator True
Then I verified Resource Group RBAC:
az role assignment list \
--scope "/subscriptions/0redacted0/resourceGroups/rg-infra-dev-azlab2" \
--query "[?principalId=='3redacted7' || principalId=='bredactede'].{role:roleDefinitionName,principalId:principalId,scope:scope}" \
-o table
Output:
Role PrincipalId Scope
----------------- ------------------------------------ --------------------------------------------------------------------------------------
Monitoring Reader bredactede /subscriptions/0redacted0/resourceGroups/rg-infra-dev-azlab2
Reader 3redacted7 /subscriptions/0ab57credacted0/resourceGroups/rg-infra-dev-azlab2
Then I verified Web App RBAC:
WEBAPP_ID=$(az webapp show \
--resource-group rg-infra-dev-azlab2 \
--name app-infra-dev-azlab2-zlngem \
--query id \
-o tsv)
az role assignment list \
--scope "$WEBAPP_ID" \
--query "[?principalId=='3redacted7'].{role:roleDefinitionName,principalId:principalId,scope:scope}" \
-o table
Output:
Role PrincipalId Scope
------------------- ------------------------------------ ------------------------------------------------------------------------------------------------------------------------------------------------
Website Contributor 3redacted7 /subscriptions/0redacted0/resourceGroups/rg-infra-dev-azlab2/providers/Microsoft.Web/sites/app-infra-dev-azlab2-zlngem
This proved:
App Roles exist in the App Registration.
Reader is assigned to lab-readers at Resource Group scope.
Monitoring Reader is assigned to lab-security-reviewers at Resource Group scope.
Website Contributor is assigned to lab-operators only at Web App scope.
What I Learned from the Terraform Conversion
This section was not just about writing Terraform code.
It was about safely adopting Terraform in an existing environment.
The main lessons were:
1. Brownfield IaC requires imports before apply.
2. Terraform state must match the real cloud environment.
3. Placeholder object IDs are dangerous.
4. App Roles and Azure RBAC solve different authorization problems.
5. App Service Easy Auth settings can be accidentally removed if Terraform does not manage or ignore them.
6. Managed Identity is for Azure resource access, not user login.
7. Role assignment import requires the role assignment resource ID, not the principal ID.
8. A 409 RoleAssignmentExists error usually means the resource already exists in Azure but is missing from Terraform state.
9. The safest Terraform workflow is:
fmt → validate → plan → inspect drift → import → plan again → apply.
By the end of Part B, the lab had:
Microsoft Entra groups imported into Terraform
App Registration imported into Terraform
Enterprise Application / Service Principal imported into Terraform
Microsoft Graph User.Read permission declared
App Roles created with Terraform
Azure RBAC corrected and verified
App Service Managed Identity enabled
Key Vault imported and protected with Azure RBAC
DemoAppSecret imported into Terraform state
Key Vault Secrets User access imported for the App Service managed identity
Portal Easy Auth protected during brownfield migration
This completed the MVP Terraform conversion for Steps 41–56.
The next step is Part C:
Replace Enterprise App Default Access assignments
with real App Role assignments:
lab-readers → LabReader
lab-operators → LabOperator
lab-security-reviewers → LabSecurityReviewer
lab-owners → LabOwner