Manages the full create/read/update/delete lifecycle of an Entra ID
application(app registration) object plus its ownedfederatedIdentityCredentialchild collection (Microsoft Graph v1.0applications/applications/{id}/federatedIdentityCredentialsendpoints), targetingmicrosoft/msgraph0.3.0.
- 🏷️ Provisions an Entra ID application registration via
POST /applications, drift-detects and patches it thereafter, and deletes it onterraform destroy. - 🧱 Mirrors the Graph
applicationresource type's documented writable surface —displayName,signInAudience,identifierUris,groupMembershipClaims,tags,notes,serviceManagementReference,info,requiredResourceAccess,api,web,spa,publicClient,appRoles,optionalClaims— as deeply-typedobjectschemas, 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_eachcomposite 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 ownid, but no independent lifecycle outside this one parent application. - 🔒 Never accepts or emits
passwordCredentialsorkeyCredentialsin any form, including metadata-only sub-fields — Graph manages those viaaddPassword/removePasswordactions, not a plainPATCH, which this module does not implement (see 🧱 Design Principles). - 🚫 Defaults
sign_in_audienceto"AzureADMyOrg"(single tenant) — the caller must opt into a broader audience explicitly. - 🆔 Its
app_idoutput (not itsidoutput) is the reference type theservice-principalmodule 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/iddistinction right here means every downstream module (service-principal, and transitivelyapp-role-assignment) inherits a safe, correctly cross-referenced foundation.
If these Terraform modules have been helpful to you or your organization, I'd appreciate your support in any of the following ways:
- ⭐ Star this repository to help others discover this Terraform module.
- 🤝 Connect with me on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
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!
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
(Validated via the Mermaid Chart MCP before embedding.)
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
(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" |
| 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/keyCredentialsare excluded from this module's schema entirely — not reduced to safe metadata fields. Graph documentspasswordCredentialsas manageable only via theaddPassword/removePasswordactions, not a plainPATCHof theapplicationsresource, 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 resultingkeyIdback into Terraform if a future, narrowly-scoped module ever needs it.uniqueNameis 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 separatePATCH /applications(uniqueName='...')upsert mechanism — not the standardPOST /applicationsthis module's keystone uses. A property Graph itself documents as read-only through this module's chosen create mechanism must not appear invariables.tfat all.appIdis Entra-generated and computed — never caller-supplied. Read back viaresponse_export_valuesintomsgraph_resource.this.output.app_id, since it is not the resource's ownidattribute and the provider has no other mechanism to surface a computedbodyproperty.api.requestedAccessTokenVersionmust be2whensign_in_audienceis"AzureADandPersonalMicrosoftAccount"or"PersonalMicrosoftAccount". This is a cross-field Graph constraint enforced with a 400 atapplytime;var.api'svalidation {}block catches the mismatch atplantime instead (see 🧪 Testing).app_roles[*].is_enabledandapi.oauth2_permission_scopes[*].is_enabledmust be flipped tofalsein 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[*].nameis 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_accessis 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'svalidation {}blocks catch both caps atplantime. Narrowingsign_in_audienceafter this collection has already grown large is a real gotcha: Graph will reject thePATCH.
| 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.
- 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.
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
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
}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 |
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
displayNameis set — the single Graph-required property.sign_in_audienceis 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"
}
⚠️ Broadeningsign_in_audienceaway 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
}
}
⚠️ Omittingapi.requested_access_token_version(or setting it to1) here failsterraform validate/plan, notapply—var.api'svalidation {}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_urismust be globally unique across the tenant's Microsoft Entra directory — Graph enforces this atapplytime against the live directory; it cannot be checked atplantime.
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 permissionid) 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"
}
}
}
}
⚠️ idon 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'sis_enabled = falsein 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 tofalse— 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}://authredirect 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_rolesare the assignment target for the plannedapp-role-assignmentaggregation 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; omitsource(or leave itnull) for a predefined optional claim likeemployeeId.
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_credentialbyname(this module'smain.tf, nevercount) means adding a fourth environment tovar.fic_environmentsnever forces Terraform to re-index — and therefore never re-plans — thedev/staging/prodcredentials 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-principalis authored, the ordering shown here is exactly what Terraform's own dependency graph enforces without anydepends_on: the service principal'sapp_idinput is populated frommodule.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 forgroup→group-owner→conditional-access-policy.
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").
| 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.
idvs.app_id— these are genuinely different Graph identities, and this module deliberately outputs both.idis the application object's id (applications/{id});app_idis the Entra-generatedappIdalternate key. Per SCOPE.md's Emits table,service-principaland otherapplicationmodules'requiredResourceAccess.resourceAppIdconsumeapp_id, notid— 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" }onmsgraph_resource.thisis load-bearing, not decorative. It is the only mechanism the provider exposes to read back a computedbodyproperty that isn't the resource's ownidattribute — confirmed against the provider's own schema (msgraph_resource'soutputcomputed attribute, populated by JMESPath queries inresponse_export_values).for_each, nevercount, forfederated_identity_credentials. Keyed by the FIC's ownnameproperty (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 toapp_rolesandapi.oauth2_permission_scopes, which are keyed maps rendered into Graph's list-of-objects JSON shape viaforexpressions insidemain.tf(deterministic sorted-key order, not a Terraform resourcefor_each, since they are inlinebodycontent, not separate sub-resources).app_roles[*].is_enabledandapi.oauth2_permission_scopes[*].is_enabledremoval is two-step, not one-step. Setis_enabled = falsein 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_resourcefor full lifecycle management — notmsgraph_update_resource.terraform destroygenuinely deletes the application object (and, transitively, everyfederated_identity_credentialchild) 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 amsgraph_update_resource-based singleton module. required_resource_accessandoptional_claimssub-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) atplantime — see SCOPE.md's authoring-time addendum under "Design decisions" for the full rationale.
| 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 |
terraform init -backend=false
terraform validate
terraform fmt -checkPin 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"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_audience ↔ api.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_urisvalues that are syntactically valid but not actually globally unique across the tenant's Microsoft Entra directory passvalidate/plancleanly and fail only atapply, with a 400 — this requires a live directory lookup this module cannot perform atplantime.federated_identity_credentials[*].issuer/subjectcombinations that satisfy this module's length validation but do not actually match the external token'siss/subclaims passplancleanly 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_accessentry referencing aresourceAppIdthat doesn't correspond to a real application in the tenant, or a permissionidthat doesn't exist on that resource app, passesplancleanly (this module only validatestypeand map-key shape) and fails only atapplywith 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 byplaneven 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.
$ 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.
| 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 |
- Graph API reference —
applicationresource type: https://learn.microsoft.com/graph/api/resources/application?view=graph-rest-1.0 - Graph API reference — create application: https://learn.microsoft.com/graph/api/application-post-applications?view=graph-rest-1.0
- Graph API reference —
federatedIdentityCredentialresource type: https://learn.microsoft.com/graph/api/resources/federatedidentitycredential?view=graph-rest-1.0 - Graph API reference —
requiredResourceAccessresource type: https://learn.microsoft.com/graph/api/resources/requiredresourceaccess?view=graph-rest-1.0 - Graph API reference —
apiApplication/permissionScoperesource types: https://learn.microsoft.com/graph/api/resources/apiapplication?view=graph-rest-1.0 · https://learn.microsoft.com/graph/api/resources/permissionscope?view=graph-rest-1.0 - Graph API reference —
appRoleresource type: https://learn.microsoft.com/graph/api/resources/approle?view=graph-rest-1.0 - Provider docs —
microsoft/msgraph0.3.0: https://registry.terraform.io/providers/microsoft/msgraph/0.3.0/docs - This module's design record —
SCOPE.md - Sibling modules —
tf-mod-msgraph-user(published),tf-mod-msgraph-service-principal,tf-mod-msgraph-app-role-assignment(both planned catalog entries as of this README)