Make life easier with Entra ID as Code

TL;DR

It is the end of 2024; daily, we use the following:

  • CI/CD pipelines for infrastructure as code (IaC) deployment to create Services, Applications, Storages, etc,
  • permissions (RBAC) from resources to resources (App Service WebApp1 should read Blob Storage WebApp1Storage),
  • secrets, we hate them, but we found a solution to avoid secrets with Managed Identity and Workload Identity solutions,

What is the plan for us? We will use Azure Portal and Entra ID blade to manage our applications, permissions, and secrets. Via browser, we can create and update our App Registrations. Can we improve our Entra ID and Entra External ID with IaC, as shown in the screenshot (Picture 1) below?

Picture 1. From Entra ID managed via Azure Portal: From

Picture 2. To Entra ID managed via Terraform: To

Problem

How many applications do our organizations use with SSO (via your company account)? How many applications are provided by your product for your customers? Is it only one application or more?

From the workforce perspective, typically, we have at least one application (Outlook, maybe Teams), and there is no maximum. Maybe you have Slack, Cloudflare, Github, Jira, etc. In addition, each custom-developed application (WebApplication, REST API) should be integrated with your Identity Provider (Entra ID).

For each application integration, you need to set up the registration:

  • name
  • redirect URL(URLs)
  • secret for confidential client
  • OpenID Connect client type
  • required permissions (scopes)

With many applications, we have a lot of manual work to do. From an operations perspective, it is a nightmare to manage it, provide a unified configuration and audit it from the first day, even before the first deployment!

Possible solutions

We see a minimum of three possible solutions that can solve the problem:

We can also use:

Bicep

  • Extremely easy to start with (no state).
  • Not production-ready: link.
  • Can’t handle state, can not detect deletes or modifications.
  • Reuse modules to repeat pattern (expected structure).
  • Can be unit tested.
  • Basic run via VS Code requires deployment to subscription. To deploy changes to the tenant without a subscription-like Entra External ID or Azure AD B2C, there is a dedicated flow: https://learn.microsoft.com/en-us/graph/templates/how-to-deploy-without-azure-sub?tabs=CLI Based on my bicep understanding, we can’t use HTTP requests to make resources that are not covered by the bicep. I’m making a point here for Terraform.

Example Bicep App Registration

provider microsoftGraph

resource UserProfile_Web 'Microsoft.Graph/applications@beta' = {
  uniqueName: 'Bicep.Demo.UserProfile.Web'
  displayName: 'Bicep.Demo.UserProfile.Web'
  signInAudience: 'AzureADMyOrg'
  web: {
    redirectUris: ['http://localhost:5031/signin-oidc','https://localhost:5030/signin-oidc']
    implicitGrantSettings: {
      enableIdTokenIssuance: false
    }
  }
  requiredResourceAccess: [
    {
     resourceAppId: '00000003-0000-0000-c000-000000000000'
     resourceAccess: [
       // User.ReadWrite
       {id: 'b4e74841-8e56-480b-be8b-910348b18b4c', type: 'Scope'}
       // User.Read
       {id: 'e1fe6dd8-ba31-4d61-89e7-88639da4683d', type: 'Scope'}
       
     ]
    }
  ]
}

resource UserProfile_ServicePrincipal 'Microsoft.Graph/applications@beta' = {
  uniqueName: 'Bicep.Demo.UserProfile.Web'
  displayName: 'Bicep.Demo.UserProfile.Web'
  signInAudience: 'AzureADMyOrg'
  web: {
    redirectUris: ['http://localhost:5031/signin-oidc','https://localhost:5030/signin-oidc']
    implicitGrantSettings: {enableIdTokenIssuance: false}
  }
  requiredResourceAccess: [
    {
     resourceAppId: '00000003-0000-0000-c000-000000000000'
     resourceAccess: [
       {id: 'df021288-bdef-4463-88db-98f22de89214', type: 'Role'}
       {id: '741f803b-c850-494e-b5df-cde7c675a1ca', type: 'Role'}
       {id: 'b0afded3-3588-46d8-8b3d-9842eff778da', type: 'Role'}
     ]
    }
  ]
}

resource UserProfile_API 'Microsoft.Graph/[email protected]' = {
  uniqueName: 'Bicep.Demo.UserProfile.API'
  displayName: 'Bicep.Demo.UserProfile.API'
  signInAudience: 'AzureADMyOrg'
  identifierUris: [
    'api://UserProfile.API'
  ]
  requiredResourceAccess: [
    {
      resourceAppId: '00000003-0000-0000-c000-000000000000'
      resourceAccess: [
        {
            id: 'e1fe6dd8-ba31-4d61-89e7-88639da4683d'
            type: 'Scope'
        }
      ]
    }
  ]
  api: {
    oauth2PermissionScopes: [      
      {
          adminConsentDescription: 'Access UserProfile API as a user'
          adminConsentDisplayName: 'Access UserProfile API as a user'
          id: '64e181b9-4ac5-402a-86fe-a958d330cfb1'
          isEnabled: true
          // origin: 'Application'
          type: 'User'
          userConsentDescription: 'Access UserProfile API as a user'
          userConsentDisplayName: 'Access UserProfile API as a user'
          value: 'access_as_user'
      }
    ]
  }
}

Why is it easy to start (maybe not with Entra ID but with other resources)

  1. Use VS Code with the Azure Bicep extension installed.
  2. Open the infra folder in VS Code.
  3. Right-click on the main.bicep file and select Deploy Bicep File like on the screen below.

Deploy Bicep File

Terraform

Example Terraform App Registration

Below, we have an example configuration for https://github.com/microsoft/woodgrove-groceries ‘profile service’ for Entra External ID setup:

resource "azuread_application" "UserProfile_Web" {
  display_name= "TF.Demo.UserProfile.Web"
  sign_in_audience = "AzureADMyOrg"
  
  api {
    mapped_claims_enabled          = true
    requested_access_token_version = 2
  }
  required_resource_access {
    # Microsoft Graph
    resource_app_id = "00000003-0000-0000-c000-000000000000"

    resource_access {
      # User.ReadWrite
      id = "b4e74841-8e56-480b-be8b-910348b18b4c"
      type = "Scope"
    }
    resource_access {
      # User.Read (sign-in and read user profile)
      id = "e1fe6dd8-ba31-4d61-89e7-88639da4683d"
      type = "Scope"
    }
  }
  web {
    redirect_uris = ["http://localhost:5031/signin-oidc", "https://localhost:5030/signin-oidc"]

    implicit_grant {
      access_token_issuance_enabled = false
      id_token_issuance_enabled     = false
    }
  }
  required_resource_access {
    resource_app_id = azuread_application.UserProfile_API.application_id
    dynamic resource_access {
      for_each = azuread_application.UserProfile_API.api.0.oauth2_permission_scope
      iterator = scope
      content {
        id   = scope.value.id
        type = "Scope"
      }
    }
  }
}
resource "azuread_application" "UserProfile_ServicePrincipal" {
  display_name= "TF.Demo.UserProfile.ServicePrincipal"
  sign_in_audience = "AzureADMyOrg"
  api {
    mapped_claims_enabled          = true
    requested_access_token_version = 2
  }
  required_resource_access {
    # Microsoft Graph    
    resource_app_id = "00000003-0000-0000-c000-000000000000"
    resource_access {
      # User.Read.All
      id = "df021288-bdef-4463-88db-98f22de89214"
      type = "Role"
    }
    resource_access {
      # User.ReadWrite.All
      id = "741f803b-c850-494e-b5df-cde7c675a1ca"
      type = "Role"
    }
    resource_access {
      # AuditLog.Read.All
      id = "b0afded3-3588-46d8-8b3d-9842eff778da"
      type = "Role"
    }
  }
}
resource "azuread_application" "UserProfile_API" {
  display_name= "TF.Demo.UserProfile.API"
  
  identifier_uris = ["api://tenant.onmicrosoft.com/user-profile-api"]

  api {
    requested_access_token_version = 2
    oauth2_permission_scope {
      admin_consent_description  = "Access UserProfile API as a user"
      admin_consent_display_name = "Access UserProfile API as a user"
      id                         = "64e181b9-4ac5-402a-86fe-a958d330cfb1"
      type                       = "User"
      user_consent_description   = "Access UserProfile API as a user"
      user_consent_display_name  = "Access UserProfile API as a user"
      value                      = "access_as_user"
    }
  }
}

What next?

  • Build a module for app registration to build each item based on the same schema.
  • Extend the IaC to manage the Conditional Access.
  • Manage External ID User Flow (via HTTP module) and Token Enrichment (also via HTTP module).

State

Picture 3.Spacelift bigger picture - Terraform (OpenTofu) runtime & state management: Terraform State

For my pet projects, I prefer to use Spacelift—https://spacelift.io/ (this is not a paid advertisement—I like to use it, and it is easy for me). And you, what do you use? Bicep or Terraform?

  • It is easy to start (moving from bicep, AWS CDK and CloudFormation).
  • It is free for personal projects.
  • Always you can manage the state via Azure Blob Storage, AWS S3, or Google Cloud Storage.

Uncovered features

What if the module is not enough for us? Is there an Entra ID feature that the module does not cover? The first option is to use GraphAPI directly—via the HTTP module in Terraform link.

Azure DevOps

Pipeline to manage Entra ID (External ID)

  • Create a pipeline to manage your app registrations.
  • Operations: all will work as designed on the pipeline, validation, and allowed list of parameters.
  • Implementation for complicated scenarios can be difficult and time-consuming.
  • Backup and audit can be done first via pipeline to store the run log or via additional layers.
  • Implementation (call GraphAPI) with PowerShell, Azure CLI, or C# (any language that can run on the pipeline).
  • Self-service registration via pipeline with approval (also possible with bicep and Terraform via Pull Request).

Summary

My personal winner is Terraform. It is stable, updated frequently, and has a lot of modules. I keep my fingers crossed for the bicep. Entra ID is a Microsoft solution like bicep, so very close cooperation and bicep, from that perspective, should get modules faster. On the other hand, it is harder to maintain (in the long term) the solution without a state. Each deletion for part of the resources, like permissions and redirect URI, should be manually managed.

And you? What is your choice? Do you prefer to use Azure DevOps pipeline, Bicep, or Terraform? Could you share your thoughts with me? Don’t hesitate to contact me via LinkedIn.

Next steps (for me)

My personal plan is to share a full example of the Entra External ID for Customers managed via Terraform for Woodgrove Groceries. With the article we can cover only basic configuration for Profile Edit Service with Backend API and basic needed permissions.