Skip to content

microsoftexpert/tf-mod-msgraph-application

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

🔷 Microsoft Graph Application Terraform Module

Manages the full create/read/update/delete lifecycle of an Entra ID application (app registration) object plus its owned federatedIdentityCredential child collection (Microsoft Graph v1.0 applications / applications/{id}/federatedIdentityCredentials endpoints), targeting microsoft/msgraph 0.3.0.

Terraform msgraph Module Type Resources


🧩 Overview

  • 🏷️ Provisions an Entra ID application registration via POST /applications, drift-detects and patches it thereafter, and deletes it on terraform destroy.
  • 🧱 Mirrors the Graph application resource type's documented writable surface — displayName, signInAudience, identifierUris, groupMembershipClaims, tags, notes, serviceManagementReference, info, requiredResourceAccess, api, web, spa, publicClient, appRoles, optionalClaims — as deeply-typed object schemas, not the provider's schema (it has none of its own to mirror — see this suite's convention on "What makes msgraph modules different").
  • 🔗 Owns one for_each composite child: applications/{id}/federatedIdentityCredentials, the workload-identity-federation mechanism used for secretless GitHub Actions OIDC and similar scenarios — a genuine Graph sub-resource with its own id, but no independent lifecycle outside this one parent application.
  • 🔒 Never accepts or emits passwordCredentials or keyCredentials in any form, including metadata-only sub-fields — Graph manages those via addPassword/removePassword actions, not a plain PATCH, which this module does not implement (see 🧱 Design Principles).
  • 🚫 Defaults sign_in_audience to "AzureADMyOrg" (single tenant) — the caller must opt into a broader audience explicitly.
  • 🆔 Its app_id output (not its id output) is the reference type the service-principal module consumes — see 🗺️ Where this fits.

💡 Why it matters: the application object is the global identity definition nearly every other Entra core-identity module in this catalog eventually depends on. Getting its secure defaults, its credential-exclusion boundary, and its app_id/id distinction right here means every downstream module (service-principal, and transitively app-role-assignment) inherits a safe, correctly cross-referenced foundation.


❤️ Support this project

If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:

Whether it's a star, a professional connection, or a coffee, every gesture helps keep these modules actively maintained and continually improving. Thank you for being part of the community!


🗺️ Where this fits

As of this module's authoring, tf-mod-msgraph-user and tf-mod-msgraph-application (this module) are the only module directories in this repository with real .tf files. tf-mod-msgraph-service-principal and tf-mod-msgraph-app-role-assignment exist only as early-stage design records, with no authored .tf files yet — confirmed via directory listing at authoring time — so the downstream boxes below represent planned catalog coverage per this suite's module suite convention, not published, consumable modules yet.

flowchart LR
 Graph["Microsoft Graph<br/>Entra ID tenant<br/>(POST/GET/PATCH/DELETE /applications)"]:::external
 WellKnown["Well-known resource apps<br/>(e.g. Microsoft Graph<br/>appId 00000003-...-c000-...)"]:::external
 App["tf-mod-msgraph-application<br/>(this module)"]:::thisModule
 SP["tf-mod-msgraph-service-principal<br/>(planned catalog entry — not yet authored)"]:::sibling
 ARA["tf-mod-msgraph-app-role-assignment<br/>(planned catalog entry — not yet authored)"]:::sibling

 App -->|"manages lifecycle of"| Graph
 WellKnown -->|"resourceAppId consumed as requiredResourceAccess reference"| App
 App -->|"app_id consumed as service principal appId"| SP
 SP -->|"id consumed as service principal principal-id"| ARA
 App -->|"id consumed as app-role assignee ids source (via service-principal)"| ARA

 classDef thisModule fill:#0078D4,color:#ffffff,stroke:#005A9E,stroke-width:2px
 classDef sibling fill:#005A9E,color:#ffffff,stroke:#003D66,stroke-width:1px
 classDef external fill:#EDEDED,color:#333333,stroke:#B0B0B0,stroke-width:1px
Loading

(Validated via the Mermaid Chart MCP before embedding.)


🧬 What this builds

One keystone resource plus one for_each-driven owned child collection — no other resources.

flowchart TD
 subgraph Inputs["Caller inputs"]
 I1["display_name (required)"]:::neutral
 I2["sign_in_audience, identifier_uris,<br/>group_membership_claims, tags, notes,<br/>service_management_reference, info,<br/>required_resource_access, api, web,<br/>spa, public_client, app_roles,<br/>optional_claims (optional)"]:::neutral
 I3["federated_identity_credentials<br/>map keyed by name (optional)"]:::neutral
 end

 This["msgraph_resource.this<br/>url = applications<br/>response_export_values.app_id = appId"]:::thisModule

 FIC["msgraph_resource.federated_identity_credential<br/>for_each over var.federated_identity_credentials<br/>url = applications/id/federatedIdentityCredentials"]:::child

 subgraph Outputs["Outputs"]
 O1["id (primary)"]:::neutral
 O2["app_id"]:::neutral
 O3["display_name"]:::neutral
 O4["federated_identity_credentials map<br/>name to id, issuer, subject"]:::neutral
 end

 I1 -->|"rendered into body"| This
 I2 -->|"rendered into body via try x null"| This
 I3 -->|"for_each key = name"| FIC
 This -->|"msgraph_resource.this.id"| FIC
 This -->|"msgraph_resource.this.id"| O1
 This -->|"output.app_id"| O2
 This -.->|"echoed from var.display_name"| O3
 FIC -->|"each.value.id keyed by name"| O4

 classDef thisModule fill:#0078D4,color:#ffffff,stroke:#005A9E,stroke-width:2px
 classDef child fill:#005A9E,color:#ffffff,stroke:#003D66,stroke-width:1px
 classDef neutral fill:#EDEDED,color:#333333,stroke:#B0B0B0,stroke-width:1px
Loading

(Validated via the Mermaid Chart MCP before embedding.)

Resource inventory

Resource Type Role
msgraph_resource.this msgraph_resource Keystone — full create/read/update/delete lifecycle of the Graph application at url = "applications"; response_export_values = { app_id = "appId" } reads back the computed appId
msgraph_resource.federated_identity_credential msgraph_resource Owned for_each child, keyed by FIC name, at url = "applications/${msgraph_resource.this.id}/federatedIdentityCredentials"

✅ Provider / Versions

Item Value
Terraform >= 1.12.0
microsoft/msgraph 0.3.0, pinned exactly (pre-1.0 provider — no ~> constraint)
Graph API version v1.0 (applications / applications/{id}/federatedIdentityCredentials endpoints)
Provider block None in this module — the caller configures provider "msgraph" {} (auth, tenant, API version) in the root module

Schema notes that bite

  • passwordCredentials/keyCredentials are excluded from this module's schema entirely — not reduced to safe metadata fields. Graph documents passwordCredentials as manageable only via the addPassword/removePassword actions, not a plain PATCH of the applications resource, which this module does not implement. Provision secrets/certificates out of band (Key Vault, a pipeline secret store, az ad app credential reset) and reference only the resulting keyId back into Terraform if a future, narrowly-scoped module ever needs it.
  • uniqueName is excluded entirely, not merely omitted from the create path. Microsoft Learn documents it as "Immutable. Read-only." with no creation-time exception, and the only Graph operation that ever writes it is the separate PATCH /applications(uniqueName='...') upsert mechanism — not the standard POST /applications this module's keystone uses. A property Graph itself documents as read-only through this module's chosen create mechanism must not appear in variables.tf at all.
  • appId is Entra-generated and computed — never caller-supplied. Read back via response_export_values into msgraph_resource.this.output.app_id, since it is not the resource's own id attribute and the provider has no other mechanism to surface a computed body property.
  • api.requestedAccessTokenVersion must be 2 when sign_in_audience is "AzureADandPersonalMicrosoftAccount" or "PersonalMicrosoftAccount". This is a cross-field Graph constraint enforced with a 400 at apply time; var.api's validation {} block catches the mismatch at plan time instead (see 🧪 Testing).
  • app_roles[*].is_enabled and api.oauth2_permission_scopes[*].is_enabled must be flipped to false in one apply before Graph allows that map entry to be removed in a subsequent apply. Removing a map entry outright, without first disabling it, produces a Graph-side rejection, not a clean delete.
  • federated_identity_credentials[*].name is immutable/force-new — it is the FIC's documented alternate key. Renaming a key in this map forces replacement of that FIC, not an in-place update. Hard cap of 20 entries per application.
  • required_resource_access is capped at 50 resource-app entries and, in total across all entries, 400 permissions (AzureADMyOrg/AzureADMultipleOrgs) or 30 permissions (PersonalMicrosoftAccount/AzureADandPersonalMicrosoftAccount)var.required_resource_access's validation {} blocks catch both caps at plan time. Narrowing sign_in_audience after this collection has already grown large is a real gotcha: Graph will reject the PATCH.

🔑 Graph API Permissions Required

Operation Application permission
Create application Application.ReadWrite.OwnedBy (higher: Application.ReadWrite.All)
Read application Application.Read.All
Update application Application.ReadWrite.OwnedBy (higher: Application.ReadWrite.All)
Delete application Application.ReadWrite.OwnedBy (higher: Application.ReadWrite.All)
Create/Update/Delete federated identity credential Application.ReadWrite.OwnedBy (higher: Application.ReadWrite.All)
Read federated identity credential Aligns with application read (Application.Read.All)

All application permissions above require admin consent.

Microsoft Graph Prerequisites

  • Graph API version: v1.0 — no beta dependency.
  • License/SKU: none beyond baseline Entra ID.
  • Admin consent: required for Application.ReadWrite.OwnedBy / Application.ReadWrite.All.
  • No tenant-level feature flag or additional license is required to create application registrations or federated identity credentials.

📁 Module Structure

tf-mod-msgraph-application/
├── providers.tf # required_version >= 1.12.0; microsoft/msgraph pinned exactly at 0.3.0;
│ # no provider {} block
├── variables.tf # 15 deeply-typed inputs mirroring the Graph v1.0 application resource type
│ # + the owned federatedIdentityCredential child collection
├── main.tf # msgraph_resource.this (keystone, url = "applications") +
│ # msgraph_resource.federated_identity_credential (for_each child)
├── outputs.tf # id (primary), app_id, display_name, federated_identity_credentials
├── SCOPE.md # design intent, resolved nested schemas, permissions, prerequisites,
│ # provider gotchas, design decisions
├── README.md # this file
└── examples/
 └── basic/
 └── main.tf # smallest real call — the one Graph-required property only

⚙️ Quick Start

terraform {
  required_version = ">= 1.12.0"

  required_providers {
    msgraph = {
      source  = "microsoft/msgraph"
      version = "0.3.0"
    }
  }
}

# Auth, tenant, and API version are configured here, by the caller — never inside this module.
provider "msgraph" {}

module "application" {
  source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-application.git?ref=v1.0.0"

  display_name = "Example Application"

  # secure default (sign_in_audience = "AzureADMyOrg") is left in place deliberately — single
  # tenant unless the caller opts into a broader audience explicitly.
}

output "application_app_id" {
  value = module.application.app_id
}

🔌 Cross-Module Contract

Consumes

Input Type Source module
required_resource_access[*] map keys (resourceAppId) string (Graph appId GUID) Well-known constant (e.g. Microsoft Graph, 00000003-0000-0000-c000-000000000000) or another application module's app_id output — nested inside this module's own body, not a top-level "consumed by id" module dependency

Emits

Output Description Consumed by
id Graph object id (GUID) of the application object (applications/{id}) — primary output, but see note Internal cross-references only. service-principal consumes app_id, not this object id — do not wire this output into a service principal's appId input
app_id The Entra-generated appId (client id), computed/read-only, Graph's alternate key service-principal module's app_id input (planned — not yet authored); other application modules' required_resource_access map keys
display_name The application's displayName, as provided to this module Informational / human-readable cross-reference
federated_identity_credentials Map of FIC name{ id, issuer, subject } External consumer (GitHub Actions / OIDC pipeline configuration) — not a sibling Terraform module

📚 Example Library

1 · Minimal required call
module "application" {
  source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-application.git?ref=v1.0.0"

  display_name = "Contoso Internal Tools"
}

ℹ️ Only displayName is set — the single Graph-required property. sign_in_audience is left at its "AzureADMyOrg" secure default; every other optional property is omitted from the payload.

2 · Opting into a multitenant audience
module "application" {
  source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-application.git?ref=v1.0.0"

  display_name     = "Contoso Multitenant SaaS"
  sign_in_audience = "AzureADMultipleOrgs"
}

⚠️ Broadening sign_in_audience away from the "AzureADMyOrg" secure default is a deliberate, explicit opt-in — the caller must type this out, per this suite's secure-by-default convention.

3 · Personal Microsoft account audience with the required token version
module "application" {
  source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-application.git?ref=v1.0.0"

  display_name     = "Contoso Consumer App"
  sign_in_audience = "AzureADandPersonalMicrosoftAccount"

  api = {
    requested_access_token_version = 2
  }
}

⚠️ Omitting api.requested_access_token_version (or setting it to 1) here fails terraform validate/plan, not applyvar.api's validation {} block enforces Graph's documented cross-field constraint ("must be 2 when signInAudience is AzureADandPersonalMicrosoftAccount or PersonalMicrosoftAccount") before any Graph call is made.

4 · Identifier URIs, tags, notes, and informational URLs
module "application" {
  source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-application.git?ref=v1.0.0"

  display_name                 = "Contoso Billing API"
  identifier_uris              = ["api://contoso-billing"]
  tags                         = ["billing", "internal"]
  notes                        = "Owned by the Billing Platform team."
  service_management_reference = "SVC-4471"

  info = {
    marketing_url        = "https://www.contoso.com/billing"
    support_url          = "https://www.contoso.com/billing/support"
    terms_of_service_url = "https://www.contoso.com/billing/terms"
  }
}

ℹ️ identifier_uris must be globally unique across the tenant's Microsoft Entra directory — Graph enforces this at apply time against the live directory; it cannot be checked at plan time.

5 · Requesting Microsoft Graph permissions via requiredResourceAccess
module "application" {
  source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-application.git?ref=v1.0.0"

  display_name = "Contoso Reporting Service"

  required_resource_access = {
    # Well-known Microsoft Graph resourceAppId
    "00000003-0000-0000-c000-000000000000" = {
      resource_access = {
        # User.Read (delegated) — well-known permission id
        "e1fe6dd8-ba31-4d61-89e7-88639da4683d" = {
          type = "Scope"
        }
        # Directory.Read.All (application) — well-known permission id
        "7ab1d382-f21e-4acd-a863-ba3e13f7da61" = {
          type = "Role"
        }
      }
    }
  }
}

🔒 The map keying on both levels (resourceAppId, then permission id) means Terraform itself rejects a caller accidentally declaring the same resource app or the same permission id twice — a plan-time guard against a Graph-side inconsistency, not just a syntactic convenience.

6 · Exposing a custom API (oauth2PermissionScopes)
module "application" {
  source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-application.git?ref=v1.0.0"

  display_name    = "Contoso Orders API"
  identifier_uris = ["api://contoso-orders"]

  api = {
    oauth2_permission_scopes = {
      "Orders.Read" = {
        id                         = "6c9e1e2a-1c1a-4b8e-9a3e-1f7b2c9d4e5a"
        admin_consent_display_name = "Read orders"
        admin_consent_description  = "Allows the app to read orders on behalf of the signed-in user."
        type                       = "User"
      }
      "Orders.ReadWrite" = {
        id                         = "8b2d4f6a-3e5c-4a1b-9d7e-2a4c6e8f0b1d"
        admin_consent_display_name = "Read and write orders"
        admin_consent_description  = "Allows the app to read and write orders on behalf of the signed-in user."
        type                       = "Admin"
      }
    }
  }
}

⚠️ id on each scope is a caller-supplied Guid — Graph does not generate one for you. To remove a scope on a later apply, first set that entry's is_enabled = false in one apply, then remove the map entry entirely in a subsequent apply — removing it directly produces a Graph-side rejection.

7 · Preauthorizing a known client application
module "application" {
  source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-application.git?ref=v1.0.0"

  display_name    = "Contoso Orders API"
  identifier_uris = ["api://contoso-orders"]

  api = {
    oauth2_permission_scopes = {
      "Orders.Read" = {
        id                         = "6c9e1e2a-1c1a-4b8e-9a3e-1f7b2c9d4e5a"
        admin_consent_display_name = "Read orders"
        admin_consent_description  = "Allows the app to read orders on behalf of the signed-in user."
        type                       = "User"
      }
    }

    pre_authorized_applications = {
      # Contoso's own first-party SPA, keyed by its appId
      "11112222-3333-4444-5555-666677778888" = {
        delegated_permission_ids = ["6c9e1e2a-1c1a-4b8e-9a3e-1f7b2c9d4e5a"]
      }
    }
  }
}

💡 Preauthorized client applications skip the user consent prompt for exactly the scopes listed — any other requested scope still requires normal consent.

8 · Web application with redirect URIs
module "application" {
  source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-application.git?ref=v1.0.0"

  display_name = "Contoso Web Portal"

  web = {
    home_page_url = "https://portal.contoso.com"
    logout_url    = "https://portal.contoso.com/logout"
    redirect_uris = ["https://portal.contoso.com/auth/callback"]

    implicit_grant_settings = {
      enable_id_token_issuance = false
    }
  }
}

🔒 implicit_grant_settings's two flags both default to false — Graph's own documented default — so the implicit flow stays off unless explicitly enabled here.

9 · Single-page application (SPA)
module "application" {
  source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-application.git?ref=v1.0.0"

  display_name = "Contoso SPA Frontend"

  spa = {
    redirect_uris = ["https://app.contoso.com/auth/callback"]
  }
}
10 · Public client (installed/mobile app)
module "application" {
  source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-application.git?ref=v1.0.0"

  display_name = "Contoso Field Technician App"

  public_client = {
    redirect_uris = ["msauth.com.contoso.fieldtech://auth"]
  }
}

ℹ️ For iOS/macOS apps, Graph documents the msauth.{BUNDLEID}://auth redirect URI convention shown here.

11 · Custom app roles for role-based access
module "application" {
  source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-application.git?ref=v1.0.0"

  display_name = "Contoso Internal Tools"

  app_roles = {
    "Admin" = {
      id                   = "3f1a5e2b-7c4d-4a9e-8b6f-1d2e3f4a5b6c"
      allowed_member_types = ["User", "Application"]
      display_name         = "Administrator"
      description          = "Full administrative access to Contoso Internal Tools."
    }
    "Viewer" = {
      id                   = "9a8b7c6d-5e4f-4a3b-2c1d-0e9f8a7b6c5d"
      allowed_member_types = ["User"]
      display_name         = "Viewer"
      description          = "Read-only access to Contoso Internal Tools."
    }
  }
}

💡 These app_roles are the assignment target for the planned app-role-assignment aggregation module — see 🏗️ End-to-end composition, below, and 🗺️ Where this fits.

12 · Optional claims from a user extension property
module "application" {
  source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-application.git?ref=v1.0.0"

  display_name = "Contoso HR Portal"

  optional_claims = {
    id_token = {
      "employeeId" = {
        essential = false
      }
    }
    access_token = {
      "extension_contoso_costCenter" = {
        essential = false
        source    = "user"
      }
    }
  }
}

ℹ️ source = "user" marks a claim sourced from a user extension property; omit source (or leave it null) for a predefined optional claim like employeeId.

13 · A single federated identity credential (GitHub Actions OIDC)
module "application" {
  source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-application.git?ref=v1.0.0"

  display_name = "Contoso CI/CD Deployer"

  federated_identity_credentials = {
    "github-actions-main" = {
      issuer      = "https://token.actions.githubusercontent.com"
      subject     = "repo:contoso/infra:ref:refs/heads/main"
      description = "GitHub Actions OIDC — contoso/infra, main branch"
      # audiences defaults to ["api://AzureADTokenExchange"]
    }
  }
}

🔒 No client secret is ever created, stored, or output for this application — the federated identity credential is the entire trust relationship. name (the map key, "github-actions-main") is immutable/force-new after creation.

14 · for_each at scale — federated identity credentials across environments
variable "fic_environments" {
  type = map(object({
    subject     = string
    description = string
  }))
  default = {
    dev = {
      subject     = "repo:contoso/infra:environment:dev"
      description = "GitHub Actions OIDC — contoso/infra, dev environment"
    }
    staging = {
      subject     = "repo:contoso/infra:environment:staging"
      description = "GitHub Actions OIDC — contoso/infra, staging environment"
    }
    prod = {
      subject     = "repo:contoso/infra:environment:prod"
      description = "GitHub Actions OIDC — contoso/infra, prod environment"
    }
  }
}

module "application" {
  source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-application.git?ref=v1.0.0"

  display_name = "Contoso CI/CD Deployer"

  federated_identity_credentials = {
    for env, cfg in var.fic_environments : "github-actions-${env}" => {
      issuer      = "https://token.actions.githubusercontent.com"
      subject     = cfg.subject
      description = cfg.description
    }
  }
}

output "federated_identity_credential_ids" {
  value = module.application.federated_identity_credentials
}

💡 Keying msgraph_resource.federated_identity_credential by name (this module's main.tf, never count) means adding a fourth environment to var.fic_environments never forces Terraform to re-index — and therefore never re-plans — the dev/staging/prod credentials already applied. Graph's hard cap of 20 FICs per application still applies across the whole map.

15 · 🏗️ End-to-end composition — application consumed by a service principal

This module's mandatory end-to-end example wires this module's app_id output — not its id output — into the concrete, real downstream dependency SCOPE.md documents: a service principal. As of this README, tf-mod-msgraph-service-principal is a planned catalog entry (confirmed via directory listing — only an early-stage design record exists there, no .tf files yet), so the second module call below is illustrative of the intended contract, not a call against a published module today.

module "internal_tools_app" {
  source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-application.git?ref=v1.0.0"

  display_name     = "Contoso Internal Tools"
  sign_in_audience = "AzureADMyOrg"

  app_roles = {
    "Admin" = {
      id                   = "3f1a5e2b-7c4d-4a9e-8b6f-1d2e3f4a5b6c"
      allowed_member_types = ["User", "Application"]
      display_name         = "Administrator"
      description          = "Full administrative access to Contoso Internal Tools."
    }
  }
}

# Illustrative only — tf-mod-msgraph-service-principal is a planned catalog entry, not yet authored.
# module "internal_tools_sp" {
# source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-service-principal.git?ref=v1.0.0"
#
# app_id = module.internal_tools_app.app_id
# }
#
# Illustrative only — tf-mod-msgraph-app-role-assignment is a planned catalog entry, not yet authored.
# module "engineering_lead_admin_role" {
# source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-app-role-assignment.git?ref=v1.0.0"
#
# service_principal_id = module.internal_tools_sp.id
# principal_id = var.engineering_lead_user_id
# app_role_id = module.internal_tools_app.app_roles["Admin"].id # illustrative accessor shape
# }

💡 Once service-principal is authored, the ordering shown here is exactly what Terraform's own dependency graph enforces without any depends_on: the service principal's app_id input is populated from module.internal_tools_app.app_id, a real output reference — Terraform will not attempt to create the service principal before the application exists. This is the same pattern this suite's worked cross-module composition example demonstrates for groupgroup-ownerconditional-access-policy.


📥 Inputs

Grouped summary

Group Variables
Required at create display_name
Identity / audience sign_in_audience (default "AzureADMyOrg"), identifier_uris, group_membership_claims
Categorization / metadata tags, notes, service_management_reference, info
Permissions requested required_resource_access
API surface exposed api (oauth2PermissionScopes, preAuthorizedApplications, requestedAccessTokenVersion)
Client-type settings web, spa, public_client
Roles and claims app_roles, optional_claims
Owned child collection federated_identity_credentials
Full variable reference (15 inputs)
Variable Type Default Validation Graph property
display_name string — (required) 1–256 characters displayName
sign_in_audience string "AzureADMyOrg" One of "AzureADMyOrg", "AzureADMultipleOrgs", "AzureADandPersonalMicrosoftAccount", "PersonalMicrosoftAccount" signInAudience
identifier_uris list(string) [] none (uniqueness enforced by Graph at apply, not plan) identifierUris
group_membership_claims string null One of "None", "SecurityGroup", "All", or null groupMembershipClaims
tags list(string) [] none tags
notes string null none notes
service_management_reference string null none serviceManagementReference
info object({ marketing_url, privacy_statement_url, support_url, terms_of_service_url }) null none info (informationalUrl)
required_resource_access map(object({ resource_access = map(object({ type })) })), keyed by resourceAppId then permission id {} type{"Scope","Role"}; ≤ 50 resource-app entries; total permissions ≤ 400/30 by sign_in_audience requiredResourceAccess
api object({ accept_mapped_claims, known_client_applications, requested_access_token_version, oauth2_permission_scopes, pre_authorized_applications }) null requested_access_token_version{1,2,null}; must be 2 when sign_in_audience requires it; oauth2_permission_scopes[*].type{"User","Admin"}; scope keys 1–120 chars, no leading . api (apiApplication)
web object({ home_page_url, logout_url, redirect_uris, implicit_grant_settings }) null none web (webApplication)
spa object({ redirect_uris }) null none spa (spaApplication)
public_client object({ redirect_uris }) null none publicClient (publicClientApplication)
app_roles map(object({ allowed_member_types, description, display_name, id, is_enabled })), keyed by value {} allowed_member_types non-empty subset of {"User","Application"}; id non-empty; keys 1–120 chars, no leading . appRoles
optional_claims object({ id_token, access_token, saml2_token }), each map(object({ essential, source, additional_properties })) keyed by name null source{"user", null} optionalClaims
federated_identity_credentials map(object({ issuer, subject, audiences, description })), keyed by name {} ≤ 20 entries; issuer/subject 1–600 chars; audiences exactly 1 value; keys 1–120 chars federatedIdentityCredential collection

Deliberately excluded from this schema (see SCOPE.md's "Design decisions" and this README's 🧱 Design Principles): passwordCredentials, keyCredentials (secret-bearing — hard rule, not a toggle), uniqueName (immutable/read-only, no settable path through this module's create mechanism), appId (computed — surfaced only via the app_id output), appRoles[*].origin (read-only, "must not be included in any POST or PATCH requests").


🧾 Outputs

Output Description Sensitive / excluded?
id Graph object id (GUID) of the application object — primary output for internal cross-references only No
app_id The Entra-generated appId (client id) — what service-principal actually consumes No
display_name The application's displayName, as provided to this module No
federated_identity_credentials Map of FIC name{ id, issuer, subject } No — FICs never carry a secret value, only external-token trust metadata

No output derived from passwordCredentials or keyCredentials exists anywhere in outputs.tf — those properties are excluded from this module's schema entirely, so there is nothing to accidentally emit.


🧠 Architecture Notes

  • id vs. app_id — these are genuinely different Graph identities, and this module deliberately outputs both. id is the application object's id (applications/{id}); app_id is the Entra-generated appId alternate key. Per SCOPE.md's Emits table, service-principal and other application modules' requiredResourceAccess.resourceAppId consume app_id, not id — wiring the wrong one into a downstream module is a real, easy-to-make mistake this README calls out explicitly (see 🔌 Cross-Module Contract and Example 15).
  • response_export_values = { app_id = "appId" } on msgraph_resource.this is load-bearing, not decorative. It is the only mechanism the provider exposes to read back a computed body property that isn't the resource's own id attribute — confirmed against the provider's own schema (msgraph_resource's output computed attribute, populated by JMESPath queries in response_export_values).
  • for_each, never count, for federated_identity_credentials. Keyed by the FIC's own name property (its documented immutable alternate key) — adding or removing one FIC from the map never re-indexes, and therefore never re-plans, any other FIC already applied. The same discipline applies to app_roles and api.oauth2_permission_scopes, which are keyed maps rendered into Graph's list-of-objects JSON shape via for expressions inside main.tf (deterministic sorted-key order, not a Terraform resource for_each, since they are inline body content, not separate sub-resources).
  • app_roles[*].is_enabled and api.oauth2_permission_scopes[*].is_enabled removal is two-step, not one-step. Set is_enabled = false in one apply, then remove the map entry in a subsequent apply. Removing the entry directly, without first disabling it, produces a Graph-side rejection — this module cannot make that two-step requirement disappear, only document it clearly.
  • This module uses msgraph_resource for full lifecycle management — not msgraph_update_resource. terraform destroy genuinely deletes the application object (and, transitively, every federated_identity_credential child) from the tenant. This is the default case per this suite's "Provider resource selection per module" convention, and SCOPE.md's "Provider resource selection" section confirms it — there is no non-destructive-destroy caveat to document for this module, unlike a msgraph_update_resource-based singleton module.
  • required_resource_access and optional_claims sub-collections are modeled as maps, not raw Graph lists, specifically to let Terraform's own map-key uniqueness catch a caller-declared duplicate (same resource app twice, same permission id twice under one resource app, same optional claim name twice in one token type) at plan time — see SCOPE.md's authoring-time addendum under "Design decisions" for the full rationale.

🧱 Design Principles

Concern Secure default in this module Opt-out (caller must be explicit)
Application sign-in audience sign_in_audience defaults to "AzureADMyOrg" (single tenant) Caller sets sign_in_audience to "AzureADMultipleOrgs", "AzureADandPersonalMicrosoftAccount", or "PersonalMicrosoftAccount" explicitly
App registration credential exposure passwordCredentials/keyCredentials are never accepted as input, never emitted as output, in any form — including metadata-only sub-fields. A hard rule, not a toggle None — there is no opt-out. Secrets are provisioned out of band (Key Vault, a pipeline secret store, az ad app credential reset)
Implicit grant flow web.implicit_grant_settings.{enable_id_token_issuance,enable_access_token_issuance} both default to false — Graph's own documented default; no hardening layered on top is needed Caller sets either flag to true explicitly
Federated identity credential trust No default FIC is ever created — federated_identity_credentials defaults to {} Caller supplies an explicit map entry per trust relationship
App role / permission scope removal is_enabled defaults to true (Graph's own default) — removal is a deliberate two-step (disable, then remove) rather than a silent one-step delete N/A — this is a Graph-enforced constraint this module documents, not a toggle
Sensitive outputs No output is ever derived from a credential-bearing property N/A — exclusion, not merely sensitive = true

🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin every real consumption of this module to an explicit tag, never a branch:

source = "git::https://github.com/microsoftexpert/tf-mod-msgraph-application.git?ref=v1.0.0"

🧪 Testing

terraform validate and terraform fmt -check prove the offline contract: display_name's length, every documented closed enum (sign_in_audience, group_membership_claims, api.oauth2_permission_scopes[*].type, app_roles[*].allowed_member_types, optional_claims.*[*].source), the required_resource_access/ app_roles/federated_identity_credentials map-key shape and count caps, and the sign_in_audienceapi.requested_access_token_version cross-field constraint are all rejected at plan time, before any Graph call is made.

What this cannot catch: msgraph_resource.this's and msgraph_resource.federated_identity_credential's body arguments are generic maps from the provider's own perspective — the provider does not know what a Graph application or federatedIdentityCredential is. Concretely, in this module:

  • identifier_uris values that are syntactically valid but not actually globally unique across the tenant's Microsoft Entra directory pass validate/plan cleanly and fail only at apply, with a 400 — this requires a live directory lookup this module cannot perform at plan time.
  • federated_identity_credentials[*].issuer/subject combinations that satisfy this module's length validation but do not actually match the external token's iss/sub claims pass plan cleanly and fail only when the external workload actually attempts the token exchange — Graph cannot validate this until a real token presentation occurs.
  • A required_resource_access entry referencing a resourceAppId that doesn't correspond to a real application in the tenant, or a permission id that doesn't exist on that resource app, passes plan cleanly (this module only validates type and map-key shape) and fails only at apply with a
  • app_roles[*].value/api.oauth2_permission_scopes[*] map keys are checked against a pragmatic approximation of Graph's documented charset (excludes whitespace, ", \, and a leading .), not a byte-for-byte enumeration of every character Graph's reference page lists as legal — an edge-case punctuation character outside this module's regex but inside Graph's actual allowed set would be rejected by plan even though Graph itself would accept it (the safer failure direction, but still worth knowing about).

This module's object typing and validation {} blocks are the only thing standing between the caller and a Graph 400 error at apply time for anything beyond these categories.


💬 Example Output

$ terraform output
app_id = "8f14e45f-ceea-4b8b-9f3d-1a2b3c4d5e6f"
display_name = "Contoso CI/CD Deployer"
federated_identity_credentials = {
 "github-actions-main" = {
 "id" = "5a4b3c2d-1e0f-4a9b-8c7d-6e5f4a3b2c1d"
 "issuer" = "https://token.actions.githubusercontent.com"
 "subject" = "repo:contoso/infra:ref:refs/heads/main"
 }
}
id = "3fa85f64-5717-4562-b3fc-2c963f66afa6"

No passwordCredentials/keyCredentials value is ever present in terraform output, terraform show, or any other output surface of this module.


🔍 Troubleshooting

Symptom Cause Fix
apply fails on create with a 400 referencing identifierUris The URI is not actually globally unique across the tenant's Microsoft Entra directory Choose a different identifier_uris value, or confirm no other application in the tenant already claims it
apply fails with a 400 referencing requestedAccessTokenVersion sign_in_audience was changed to "AzureADandPersonalMicrosoftAccount"/"PersonalMicrosoftAccount" without api.requested_access_token_version = 2 Set api.requested_access_token_version = 2 explicitly — this module's validation {} block should have caught this at plan; if it didn't, confirm you're on the version of this module that includes the cross-field check
apply fails removing an app_roles/api.oauth2_permission_scopes map entry The entry was removed directly without first setting is_enabled = false in a prior apply Re-add the entry with is_enabled = false, apply, then remove the entry entirely in a subsequent apply
apply fails on a federated identity credential referencing a count limit The application already has 20 federated identity credentials — Graph's hard cap Remove an existing FIC before adding a new one, or reconsider whether every entry is still needed
Downstream service-principal module (once authored) fails to find the application The wrong output was wired in — id (object id) was passed where app_id (the appId alternate key) was expected Wire module.application.app_id into the service principal's app_id input, not module.application.id — see 🔌 Cross-Module Contract
A just-created application isn't found by an immediate downstream read (e.g. a service principal creation attempt right after) Graph's Entra ID write path is not always immediately read-consistent Re-run plan/apply, or add an explicit wait/retry in the consuming module rather than assuming instant consistency

🔗 Related Docs

Packages

 
 
 

Contributors

Languages