Skip to content

Latest commit

Β 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

πŸš€ Azure AD Service Principal Terraform Module

Composite module that manages an Entra ID service principal (enterprise application) and every credential and grant that hangs off it β€” client-secret passwords, certificate credentials, a SAML token-signing certificate, delegated OAuth2 permission grants, claims-mapping policy assignments, and app-role assignments β€” behind one stable, deeply-typed, secure-by-default boundary. Built for azuread v3.x.

Terraform azuread module type resources


🧩 Overview

This module instantiates an application as a tenant-local service principal (what the Entra admin center calls an Enterprise Application) and wires up everything that lives on that instance:

  • πŸͺͺ Service principal (azuread_service_principal.this) β€” the keystone, linked to an existing application via client_id, with full scalar config (account enabled, app-role-assignment requirement, SSO mode, alternative names, SAML SSO, feature tags).
  • πŸ” Password credentials (azuread_service_principal_password.this) β€” client secrets as a keyed for_each collection, with a forced 1-year default expiry.
  • πŸ“œ Certificate credentials (azuread_service_principal_certificate.this) β€” a for_each collection; the whole input is sensitive because each entry carries credential value.
  • 🏷️ Token signing certificate (azuread_service_principal_token_signing_certificate.this) β€” a single (0-or-1) certificate for signing SAML tokens; Azure AD generates the key pair.
  • 🀝 Delegated permission grants (azuread_service_principal_delegated_permission_grant.this) β€” authorize this SP to call a resource API on behalf of users (OAuth2 delegated consent).
  • 🧬 Claims-mapping policy assignments (azuread_service_principal_claims_mapping_policy_assignment.this) β€” bind claims-mapping policies to this SP.
  • 🎟️ App-role assignments (azuread_app_role_assignment.this) β€” grant an app role of a resource SP to a principal; either side defaults to this SP.

πŸ’‘ Why it matters: The service principal is where an application actually acts and is granted access inside a tenant β€” its credentials sign in, its delegated grants consent to APIs, and its app-role assignments are how application permissions are realized. Managing the SP and all its credentials/grants as one typed, secure-by-default module means rotation, consent, and role grants never drift, and downstream role assignments, group membership, and provisioning jobs compose off one predictable contract.


❀️ 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 in the family

terraform-azuread-service-principal sits directly downstream of terraform-azuread-application β€” it consumes that module's client_id β€” and feeds role assignments, provisioning, and claims policies.

flowchart TD
 app["terraform-azuread-application<br/>(app registration β€” emits client_id)"]
 sp["terraform-azuread-service-principal<br/>(SP + creds + grants + assignments)"]
 grp["terraform-azuread-group"]
 dir["terraform-azuread-directory-role"]
 ap["terraform-azuread-access-package"]
 sync["terraform-azuread-synchronization"]
 claims["terraform-azuread-claims-mapping-policy"]

 app -->|client_id| sp
 claims -->|claims_mapping_policy_id| sp
 sp -->|object_id| dir
 sp -->|object_id| grp
 sp -->|object_id| ap
 sp -->|object_id / id| sync

 style sp fill:#8957E5,color:#fff
 style app fill:#0078D4,color:#fff
Loading

🧬 What this module builds

Every resource hangs off the keystone azuread_service_principal.this. Child resources wire to it via its resource ID (azuread_service_principal.this.id, form /servicePrincipals/{object_id}) or its object ID (azuread_service_principal.this.object_id).

flowchart TD
 this["azuread_service_principal.this<br/>(keystone)"]

 tspw["time_static.password<br/>(for_each, expiry anchor)"]
 tscert["time_static.certificate<br/>(for_each, expiry anchor)"]
 pw["azuread_service_principal_password.this<br/>(for_each)"]
 cert["azuread_service_principal_certificate.this<br/>(for_each, sensitive)"]
 tsc["azuread_service_principal_token_signing_certificate.this<br/>(0-or-1, SAML)"]
 dpg["azuread_service_principal_delegated_permission_grant.this<br/>(for_each)"]
 cmp["azuread_service_principal_claims_mapping_policy_assignment.this<br/>(for_each)"]
 ara["azuread_app_role_assignment.this<br/>(for_each)"]

 this -->|.id| pw
 this -->|.id| cert
 this -->|.id| tsc
 this -->|.object_id| dpg
 this -->|.id| cmp
 this -->|.object_id| ara
 tspw -.->|rfc3339 expiry anchor| pw
 tscert -.->|rfc3339 expiry anchor| cert

 style this fill:#0078D4,color:#fff
Loading

ℹ️ data.azuread_client_config.current, data.azuread_service_principal.lookup, and data.azuread_service_principals.lookup are intentionally not wired into the diagram above β€” per main.tf they are standalone read-only lookups (feeding only the current_object_id/tenant_id/*_lookups outputs) and do not participate in the keystone's resource graph.

Resource inventory (7 resource types):

  • azuread_service_principal.this β€” keystone (enterprise application instance)
  • azuread_service_principal_password.this β€” client secrets (for_each)
  • azuread_service_principal_certificate.this β€” certificate credentials (for_each, sensitive input)
  • azuread_service_principal_token_signing_certificate.this β€” SAML token signing certificate (0-or-1)
  • azuread_service_principal_delegated_permission_grant.this β€” delegated OAuth2 grants (for_each)
  • azuread_service_principal_claims_mapping_policy_assignment.this β€” claims-mapping policy assignments (for_each)
  • azuread_app_role_assignment.this β€” app-role assignments (for_each)

Data sources: azuread_client_config (current caller), azuread_service_principal (single lookup, for_each), azuread_service_principals (bulk lookup, for_each).


βœ… Provider / Versions

From providers.tf:

Requirement Constraint
Terraform >= 1.12.0
hashicorp/azuread >= 2.0, < 4.0 (validated against v3.9.0)
hashicorp/time >= 0.9 (resolves to v0.14.0)

ℹ️ The module declares the provider requirement only β€” it configures no provider "azuread" {} block, so it composes cleanly under any root provider configuration. data.azuread_client_config.current works without module-level provider config.

ℹ️ hashicorp/time dependency: the azuread provider deprecated the native end_date_relative field on azuread_service_principal_password and azuread_service_principal_certificate. To preserve the relative-expiry convenience (and the 1-year default) without a deprecation warning, the module captures a stable per-credential timestamp via time_static and renders it into an absolute end_date with timeadd. The anchor is captured once at create, so there is no plan drift. The module's public end_date_relative input is unchanged.

⚠️ v3.x naming & derivation: the SP links to its application via client_id (not the legacy application_id). A service principal has no settable display_name β€” it is derived from the linked application and exposed only as an output. Child credentials link to the SP by its resource ID (azuread_service_principal.this.id); grants and app-role assignments link by object ID (azuread_service_principal.this.object_id).


πŸ”‘ Graph API Permissions Required

The Terraform service principal must hold these before apply will succeed (sourced from SCOPE.md, enriched with Microsoft Learn).

Permission Type Required for
Application.ReadWrite.OwnedBy Application Creating/managing the SP and its credentials when the Terraform SP owns both the linked application and the service principal β€” the least-privilege default. Requires the SP to be in var.owners.
Application.ReadWrite.All Application Managing the SP and credentials when the Terraform SP is not an owner (broader than least-privilege).
AppRoleAssignment.ReadWrite.All (+ Application.Read.All or Directory.Read.All) Application var.app_role_assignments β€” granting app roles to/for this SP.
DelegatedPermissionGrant.ReadWrite.All Application var.delegated_permission_grants β€” creating OAuth2 delegated permission grants.
Policy.ReadWrite.ApplicationConfiguration and Policy.Read.All Application var.claims_mapping_policy_assignments β€” assigning claims-mapping policies.
Application.Read.All (or Directory.Read.All) Application The data.azuread_service_principal / data.azuread_service_principals read-only lookups.

⚠️ Admin consent required. All of the above are application permissions and require tenant admin consent. When Terraform runs as a user principal instead of an SP, the user needs the Application Administrator (or Cloud Application Administrator / Global Administrator) directory role.

⚠️ Member-affecting, immediate, irreversible. Granting a delegated permission for all users (omitting user_object_id β†’ tenant-wide admin consent) and creating app-role assignments take effect immediately and are not subject to review (MS Learn). Route these to an admin / Compliance review before apply.

ℹ️ No P1/P2 license is required for service principal management itself. (Downstream consumers such as PIM or Conditional Access require Entra ID P2 β€” those live in other modules.)


πŸ“ Module Structure

terraform-azuread-service-principal/
β”œβ”€β”€ providers.tf # Terraform >= 1.12.0, azuread >= 2.0, < 4.0 (no provider block)
β”œβ”€β”€ variables.tf # deeply-typed object schemas, optional defaults, validation {}
β”œβ”€β”€ main.tf # total renderer: keystone + 6 child resources + 2 time_static helpers + 3 data sources
β”œβ”€β”€ outputs.tf # object_id (primary), client_id, sensitive credential outputs
β”œβ”€β”€ SCOPE.md # cross-module contract + Graph API permissions
└── README.md # this file

βš™οΈ Quick Start

module "sp" {
  source = "git::https://github.com/microsoftexpert/terraform-azuread-service-principal?ref=v1.0.0"

  client_id = module.app.client_id # from terraform-azuread-application

  # Keep the Terraform SP as an owner so it can manage the SP after creation.
  owners = [module.sp.current_object_id]
}

ℹ️ Self-referencing module.sp.current_object_id works because the value comes from the azuread_client_config data source (known at plan time), not from the SP resource.


πŸ”Œ Cross-Module Contract

Consumes

Input Type Source
client_id string (client ID) terraform-azuread-application β†’ client_id (required)
owners set(string) (object IDs) Caller β€” users/SPs; include the Terraform SP (current_object_id)
claims_mapping_policy_assignments[*].claims_mapping_policy_id string terraform-azuread-claims-mapping-policy β†’ id
delegated_permission_grants[*].resource_service_principal_object_id string (object ID) The resource API's SP (e.g. the Microsoft Graph SP)
app_role_assignments[*].resource_object_id / principal_object_id string (object ID) Resource SP / principal (user, group, or SP)

Emits

Output Description Typically consumed by
object_id Service principal object ID (primary key) Role assignments, group membership, delegated grants, access packages, terraform-azuread-synchronization (service_principal_id)
client_id Client (application) ID of the linked application OAuth2 flows, OIDC configuration, downstream app config
id Resource ID (/servicePrincipals/{object_id}) Out-of-module azuread_service_principal_* resources
display_name Display name (read-only, derived from the linked app) Logging, audit, portal identification
application_tenant_id Tenant ID where the linked app is registered Cross-tenant composition
type Application or ManagedIdentity Health checks, governance
sign_in_audience Derived account-types audience Multi-tenant validation
service_principal_names Identifier URI(s) + client ID API addressing, audiences
app_role_ids / oauth2_permission_scope_ids Maps of role/scope value β†’ UUID seen on this SP Resolving role/scope IDs for assignments and grants
saml_metadata_url SAML federation metadata URL Relying-party / SP configuration
tenant_id / current_object_id Caller tenant / SP object ID Self-assign as owner via var.owners
passwords πŸ” SENSITIVE / write-only β†’ { key_id, value, end_date } Key Vault secret storage; never log
password_key_ids Non-sensitive password key IDs Audit, rotation tracking
certificate_key_ids Certificate key IDs Audit, rotation tracking
token_signing_certificate { key_id, thumbprint, start_date, end_date } or null Setting the preferred SAML signing certificate
token_signing_certificate_value πŸ” SENSITIVE β€” PEM public signing cert body or null Relying-party / service-provider configuration
delegated_permission_grant_ids / claims_mapping_policy_assignment_ids / app_role_assignment_ids Maps of child resource IDs keyed by input key Downstream references, audit
service_principal_lookups / service_principals_lookups Read-only lookup results Referencing pre-existing SPs

πŸ“š Example Library

1 Β· Minimal β€” smallest call that creates a real service principal
module "sp" {
  source    = "git::https://github.com/microsoftexpert/terraform-azuread-service-principal?ref=v1.0.0"
  client_id = module.app.client_id
}

Creates only azuread_service_principal.this with secure defaults: account_enabled = true, app_role_assignment_required = true (the application is not silently open to every identity), no credentials, no owners.

2 Β· Description, login URL & SSO mode
module "sp" {
  source    = "git::https://github.com/microsoftexpert/terraform-azuread-service-principal?ref=v1.0.0"
  client_id = module.app.client_id

  description                   = "Member portal enterprise application"
  login_url                     = "https://portal.example.com/sso/start"
  preferred_single_sign_on_mode = "saml" # one of: oidc, password, saml, notSupported
  notification_email_addresses  = ["sso-admins@financialpartners.com"]
}

ℹ️ notification_email_addresses is where Entra warns before the SAML token-signing certificate expires (see Example 5).

3 Β· A client secret (password credential)
module "sp" {
  source    = "git::https://github.com/microsoftexpert/terraform-azuread-service-principal?ref=v1.0.0"
  client_id = module.app.client_id

  passwords = {
    primary = {
      display_name      = "primary-secret"
      end_date_relative = "4320h" # 180 days β€” under Microsoft's 12-month recommendation
    }
  }
}

# Store the write-only value immediately β€” it cannot be re-read.
output "sp_secret" {
  value     = module.sp.passwords["primary"].value
  sensitive = true
}
4 Β· A certificate credential
module "sp" {
  source    = "git::https://github.com/microsoftexpert/terraform-azuread-service-principal?ref=v1.0.0"
  client_id = module.app.client_id

  certificates = {
    signing = {
      value    = file("${path.module}/certs/sp-public.pem") # public cert (.pem/.cer/.crt)
      type     = "AsymmetricX509Cert"                       # or "Symmetric"
      encoding = "pem"                                      # one of: pem, base64, hex
    }
  }
}

ℹ️ var.certificates is marked sensitive = true because each entry carries credential value. The module iterates its keys with nonsensitive since the map keys are not secret.

5 Β· SAML SSO β€” token signing certificate (core feature)
module "sp" {
  source    = "git::https://github.com/microsoftexpert/terraform-azuread-service-principal?ref=v1.0.0"
  client_id = module.app.client_id

  preferred_single_sign_on_mode = "saml"
  notification_email_addresses  = ["sso-admins@financialpartners.com"]

  saml_single_sign_on = {
    relay_state = "https://portal.example.com/landing"
  }

  token_signing_certificate = {
    display_name = "CN=casey-portal-saml-signing" # MUST start with "CN="
    end_date     = "2027-06-18T00:00:00Z"
  }
}

output "saml_thumbprint" {
  value = module.sp.token_signing_certificate.thumbprint
}

⚠️ Token signing certificates expire. Use notification_email_addresses for expiry warnings and rotate before end_date. Azure AD generates the key pair β€” you supply no certificate material. The PEM body is available (sensitive) as module.sp.token_signing_certificate_value.

6 Β· Delegated permission grant β€” admin consent for all users
# The resource API's service principal β€” here, Microsoft Graph.
data "azuread_service_principal" "msgraph" {
  client_id = "00000003-0000-0000-c000-000000000000"
}

module "sp" {
  source    = "git::https://github.com/microsoftexpert/terraform-azuread-service-principal?ref=v1.0.0"
  client_id = module.app.client_id

  delegated_permission_grants = {
    graph_user_read = {
      resource_service_principal_object_id = data.azuread_service_principal.msgraph.object_id
      claim_values                         = ["User.Read", "openid", "profile"]
      # user_object_id OMITTED -> admin consent for ALL users (tenant-wide)
    }
  }
}

⚠️ Omitting user_object_id grants tenant-wide admin consent (consentType = AllPrincipals) β€” it takes effect immediately and is not subject to review. To consent for a single user instead, set user_object_id. The module always anchors the grant's service_principal_object_id to this SP.

7 Β· App-role assignments β€” this SP as principal and as resource
# (a) THIS SP as the PRINCIPAL β€” grant a Microsoft Graph application permission to this SP.
data "azuread_service_principal" "msgraph" {
  client_id = "00000003-0000-0000-c000-000000000000"
}

variable "loan_admins_group_object_id" { type = string } # (b) principal for the second assignment

module "sp" {
  source    = "git::https://github.com/microsoftexpert/terraform-azuread-service-principal?ref=v1.0.0"
  client_id = module.app.client_id

  app_role_assignments = {
    graph_user_read_all = {
      # principal_object_id OMITTED -> defaults to THIS service principal
      resource_object_id = data.azuread_service_principal.msgraph.object_id
      app_role_id        = "df021288-bdef-4463-88db-98f22de89214" # User.Read.All (application)
    }
    # (b) THIS SP as the RESOURCE β€” assign a role this app exposes to a group.
    loan_admin_for_group = {
      # resource_object_id OMITTED -> defaults to THIS service principal
      principal_object_id = var.loan_admins_group_object_id
      app_role_id         = module.sp.app_role_ids["Loan.Admin"] # a role this app exposes
    }
  }
}

ℹ️ (a) is the application-permission pattern: this SP (the principal) is granted an app role exposed by Microsoft Graph (the resource). (b) is the reverse: this SP is the resource, and a group is the principal. Leave app_role_id at its default 00000000-0000-0000-0000-000000000000 for "default access" (no specific role). Supply at least one of principal_object_id / resource_object_id per entry β€” omitting both assigns the role from this SP to itself.

8 Β· Claims-mapping policy assignment
module "sp" {
  source    = "git::https://github.com/microsoftexpert/terraform-azuread-service-principal?ref=v1.0.0"
  client_id = module.app.client_id

  claims_mapping_policy_assignments = {
    employee_claims = {
      claims_mapping_policy_id = module.claims_policy.id # from terraform-azuread-claims-mapping-policy
    }
  }
}

ℹ️ The module anchors each assignment's service_principal_id to this SP β€” the caller supplies only the policy ID.

9 Β· Feature tags (enterprise app, hidden from My Apps)
module "sp" {
  source    = "git::https://github.com/microsoftexpert/terraform-azuread-service-principal?ref=v1.0.0"
  client_id = module.app.client_id

  feature_tags = {
    enterprise = true # WindowsAzureActiveDirectoryIntegratedApp
    hide       = true # HideApp β€” invisible in My Apps / Office 365 launcher
  }
  # Do NOT also set var.tags β€” the two are mutually exclusive (validated).
}

⚠️ feature_tags and tags are mutually exclusive β€” a validation {} block rejects setting both, and the module forces tags = null whenever feature_tags is set. Use raw tags = ["WindowsAzureActiveDirectoryIntegratedApp",...] only when you need a tag value feature_tags can't express.

10 Β· for_each at scale β€” three password credentials from one map (stable keys)
module "sp" {
  source    = "git::https://github.com/microsoftexpert/terraform-azuread-service-principal?ref=v1.0.0"
  client_id = module.app.client_id

  passwords = {
    # Keys are STABLE identifiers β€” renaming a key destroys & recreates that secret.
    api-primary = {
      display_name      = "api-primary"
      end_date_relative = "8760h" # 1 year
    }
    api-secondary = {
      display_name      = "api-secondary"
      end_date_relative = "8760h"
    }
    batch-runner = {
      display_name = "batch-runner"
      end_date     = "2027-01-01T00:00:00Z" # absolute expiry (don't combine with end_date_relative)
    }
  }
}

output "sp_secret_key_ids" {
  value = module.sp.password_key_ids # non-sensitive, safe to log
}
11 Β· Credential rotation via rotate_when_changed
variable "secret_rotation_id" {
  type    = string
  default = "2026-q2" # bump this token (or wire to time_rotating.id) to force a new secret
}

module "sp" {
  source    = "git::https://github.com/microsoftexpert/terraform-azuread-service-principal?ref=v1.0.0"
  client_id = module.app.client_id

  passwords = {
    primary = {
      display_name        = "primary-secret"
      end_date_relative   = "8760h" # 1 year
      rotate_when_changed = { rotation = var.secret_rotation_id }
    }
  }
}

ℹ️ Changing any value in rotate_when_changed forces Terraform to mint a new secret. Pair with the hashicorp/time provider (time_rotating) for automatic time-based rotation. Capture the new value from the sensitive passwords output on every apply.

12 Β· With explicit owners β€” assign named owners alongside the Terraform SP
data "azuread_client_config" "current" {}

variable "sp_admin_user_object_id" { type = string }    # e.g. an IT owner of this app
variable "app_admins_group_object_id" { type = string } # e.g. an "SP admins" security group

module "sp" {
  source    = "git::https://github.com/microsoftexpert/terraform-azuread-service-principal?ref=v1.0.0"
  client_id = module.app.client_id

  owners = [
    data.azuread_client_config.current.object_id, # the Terraform SP β€” retains management rights
    var.sp_admin_user_object_id,                  # named human owner
    var.app_admins_group_object_id,               # named group owner
  ]
}

ℹ️ owners accepts any mix of user, group, and service-principal object IDs. Always include the Terraform SP's own object ID β€” here via data.azuread_client_config.current.object_id (equivalent to this module's own current_object_id output) β€” so it retains the ability to manage the SP on later applies; see the Troubleshooting table for what happens if it's omitted.

13 Β· Hardened β€” most secure -compliant variant (certificate-only, assignment required, explicit owners)
module "sp" {
  source    = "git::https://github.com/microsoftexpert/terraform-azuread-service-principal?ref=v1.0.0"
  client_id = module.app.client_id

  description = "Production confidential client β€” certificate auth only, no client secrets."

  account_enabled              = true
  app_role_assignment_required = true # require explicit assignment (secure default, shown for intent)

  # Prefer certificate credentials over client secrets in production
  certificates = {
    signing = {
      value             = file("${path.module}/certs/prod-public.pem")
      type              = "AsymmetricX509Cert"
      encoding          = "pem"
      end_date_relative = "8760h"
    }
  }

  feature_tags = {
    hide = true # not surfaced to end users in My Apps
  }

  owners = [module.sp.current_object_id] # Terraform SP retains management rights
}
14 Β· Read-only lookups (existing service principals)
module "sp" {
  source    = "git::https://github.com/microsoftexpert/terraform-azuread-service-principal?ref=v1.0.0"
  client_id = module.app.client_id

  service_principal_lookups = {
    msgraph = { client_id = "00000003-0000-0000-c000-000000000000" }
  }

  service_principals_lookups = {
    portals    = { display_names = ["casey-member-portal", "casey-admin-portal"] }
    everything = { return_all = true } # cannot combine with ignore_missing
  }
}

output "msgraph_object_id" {
  value = module.sp.service_principal_lookups["msgraph"].object_id
}
15 Β· End-to-end composition (mandatory) β€” upstream β†’ this module β†’ downstream consumers
# --- Upstream: the application registration ----------------------------------
module "app" {
  source       = "git::https://github.com/microsoftexpert/terraform-azuread-application?ref=v1.0.0"
  display_name = "casey-loan-origination-api"
  owners       = [module.app.current_object_id]
}

# --- The resource API's SP (Microsoft Graph) ---------------------------------
data "azuread_service_principal" "msgraph" {
  client_id = "00000003-0000-0000-c000-000000000000"
}

# --- This module: the service principal --------------------------------------
module "sp" {
  source    = "git::https://github.com/microsoftexpert/terraform-azuread-service-principal?ref=v1.0.0"
  client_id = module.app.client_id # <-- wired from the application module

  app_role_assignment_required = true
  owners                       = [module.sp.current_object_id]

  # Grant this SP an application permission on Microsoft Graph
  app_role_assignments = {
    graph_user_read_all = {
      resource_object_id = data.azuread_service_principal.msgraph.object_id
      app_role_id        = "df021288-bdef-4463-88db-98f22de89214" # User.Read.All
    }
  }

  passwords = {
    primary = { display_name = "primary-secret", end_date_relative = "8760h" }
  }
}

# --- Downstream: provisioning job keyed by the SP ----------------------------
module "sync" {
  source               = "git::https://github.com/microsoftexpert/terraform-azuread-synchronization?ref=v1.0.0"
  service_principal_id = module.sp.object_id # <-- this module's primary output
}

# --- Downstream: store the write-only secret in Key Vault --------------------
resource "azurerm_key_vault_secret" "sp" {
  name         = "loan-api-sp-secret"
  value        = module.sp.passwords["primary"].value # sensitive -> sensitive
  key_vault_id = var.kv_id
}

πŸ“₯ Inputs

Full input reference (object schemas)

Required

Name Type Description
client_id string Client (application) ID of the linked application. Must be non-empty.

Service principal scalar config

Name Type Default Notes
account_enabled bool true Whether the SP account is enabled.
app_role_assignment_required bool true Secure default β€” require explicit user/group assignment before a token is issued.
alternative_names set(string) [] Alternative names (managed-identity subscription/RG/resource-id retrieval).
description string null Internal end-user description.
login_url string null IdP-initiated sign-on launch URL.
notes string null Operational free-text.
notification_email_addresses set(string) [] SAML signing-cert expiry notifications.
preferred_single_sign_on_mode string null Enum-validated: oidc / password / saml / notSupported (or null).
use_existing bool false Import any pre-existing SP linked to the same app (e.g. first-party Microsoft apps).
owners set(string) [] Owner object IDs β€” include the Terraform SP (current_object_id).

Single optional blocks

saml_single_sign_on = object({ # default null (0-or-1 block)
  relay_state = optional(string)
})

feature_tags = object({ # default null (0-or-1 block); EXCLUSIVE with tags
  custom_single_sign_on = optional(bool, false)
  enterprise            = optional(bool, false)
  gallery               = optional(bool, false)
  hide                  = optional(bool, false)
})

tags = set(string) # default []; EXCLUSIVE with feature_tags

token_signing_certificate = object({ # default null (0-or-1 resource)
  display_name = optional(string)    # MUST start with "CN=" when set
  end_date     = optional(string)
})

Collections (all for_each over map(object) keyed by a caller-supplied stable string)

passwords = map(object({ # default {}
  display_name        = optional(string)
  end_date            = optional(string)
  end_date_relative   = optional(string, "8760h")
  start_date          = optional(string)
  rotate_when_changed = optional(map(string), {})
}))

certificates = map(object({ # default {} β€” SENSITIVE
  value             = string
  type              = optional(string, "AsymmetricX509Cert") # AsymmetricX509Cert | Symmetric
  encoding          = optional(string, "pem")                # pem | base64 | hex
  end_date          = optional(string)
  end_date_relative = optional(string, "8760h")
  start_date        = optional(string)
  key_id            = optional(string)
}))

delegated_permission_grants = map(object({ # default {}
  resource_service_principal_object_id = string
  claim_values                         = set(string)
  user_object_id                       = optional(string) # omit -> admin consent for ALL users
}))

claims_mapping_policy_assignments = map(object({ # default {}
  claims_mapping_policy_id = string
}))

app_role_assignments = map(object({ # default {}
  app_role_id         = optional(string, "00000000-0000-0000-0000-000000000000")
  principal_object_id = optional(string) # omit -> THIS SP
  resource_object_id  = optional(string) # omit -> THIS SP
}))

service_principal_lookups = map(object({ # default {}
  client_id    = optional(string)
  display_name = optional(string)
  object_id    = optional(string)
}))

service_principals_lookups = map(object({ # default {}
  client_ids     = optional(list(string))
  display_names  = optional(list(string))
  object_ids     = optional(list(string))
  return_all     = optional(bool, false)
  ignore_missing = optional(bool, false)
}))

Universal tail

timeouts = object({ # default {}
  create = optional(string), read = optional(string)
  update = optional(string), delete = optional(string)
})

🧾 Outputs

Output Sensitive Notes
object_id Primary key for downstream wiring
client_id Client (application) ID of the linked app
id Resource ID used as service_principal_id by child resources
display_name Read-only, derived from the linked application
application_tenant_id Tenant of the linked app
type Application or ManagedIdentity
sign_in_audience Derived account-types audience
service_principal_names Identifier URI(s) + client ID
app_role_ids Map of role value β†’ UUID
oauth2_permission_scope_ids Map of scope value β†’ UUID
saml_metadata_url SAML federation metadata URL
tenant_id From data.azuread_client_config
current_object_id Caller SP object ID β€” add to owners
passwords πŸ” yes Write-only map { key_id, value, end_date } β€” capture immediately
password_key_ids Non-sensitive key IDs for audit
certificate_key_ids
token_signing_certificate { key_id, thumbprint, start_date, end_date } or null
token_signing_certificate_value πŸ” yes PEM public signing cert body or null
delegated_permission_grant_ids Map of grant IDs
claims_mapping_policy_assignment_ids Map of assignment IDs
app_role_assignment_ids Map of assignment IDs
service_principal_lookups { object_id, client_id, display_name } per key
service_principals_lookups { object_ids, client_ids, display_names } per key

πŸ” passwords and token_signing_certificate_value are the sensitive outputs. A password value is returned by the Graph API only at creation and can never be re-read. Consume these directly into a secret store; never interpolate them into a non-sensitive output or a log line.


🧠 Architecture Notes

The for_each over map(object) pattern β€” key stability

Every repeating collection is a map(object(...)) keyed by a caller-supplied stable string (no count). The key becomes the resource's state address (e.g. azuread_service_principal_password.this["primary"]).

  • Stable keys = stable state. Adding or removing a map entry only touches that one resource; sibling credentials/grants are untouched.
  • Renaming a key is a destroy + recreate of that one child. For credentials this means the old secret/cert is deleted and a new one minted β€” plan output will show this clearly. Choose keys you won't need to rename (e.g. primary, api-secondary, not secret-1).
  • count is deliberately avoided β€” list indices shift on insert/remove and cascade-recreate everything after the change.

nonsensitive and the certificate collection

var.certificates is marked sensitive = true because each object carries credential value. Terraform forbids using a sensitive value directly as for_each, so main.tf iterates with:

for_each = toset(nonsensitive(keys(var.certificates)))

This is safe because the map keys are not secret β€” only the value fields are. The sensitive value still flows into the resource's value argument unchanged; only the key set is de-sensitised for iteration.

Sensitive outputs β€” why and how to consume

The passwords output is sensitive = true because Entra returns a secret's value once, at creation ("never displayed again"). If it weren't sensitive, the secret would land in plan output, console logs, and CI artifacts permanently. token_signing_certificate_value is likewise marked sensitive per credential policy.

Safe consumption:

resource "azurerm_key_vault_secret" "sp" {
  name         = "loan-api-sp-secret"
  value        = module.sp.passwords["primary"].value # sensitive flows to sensitive
  key_vault_id = var.kv_id
}

Never assign a sensitive value to a non-sensitive output, and never join/format it into a log string. The parallel password_key_ids / certificate_key_ids outputs exist for non-sensitive audit/rotation tracking.

tags vs feature_tags β€” mutual exclusivity

The provider rejects a service principal that sets both tags and feature_tags. The module enforces this two ways: a validation {} block on var.tags fails the plan if both are supplied, and main.tf renders tags = var.feature_tags == null ? var.tags: null so the provider never receives both even at the boundary. Note that any tags configured on the linked application also propagate to this SP automatically.

Anchored sides β€” the SP is always one end of each grant

Two child resources fix one side to this SP so the caller can't accidentally point them elsewhere:

  • delegated_permission_grant.service_principal_object_id = this.object_id (the SP being authorized).
  • claims_mapping_policy_assignment.service_principal_id = this.id.

app_role_assignment is the flexible case: either principal_object_id (this SP receives a role β€” application-permission pattern) or resource_object_id (this SP exposes a role assigned to a principal) defaults to this.object_id via coalesce. Supply at least one of the two; leaving both null is a self-assignment.

Resource creation ordering (7 resource types)

All child resources reference azuread_service_principal.this (its .id or .object_id), so Terraform derives the dependency graph automatically β€” the keystone SP is created first, then every child in parallel. No explicit depends_on is needed. data.azuread_client_config.current resolves at plan time; the lookup data sources resolve against whatever already exists in the tenant. The SP itself depends on the linked application existing first (var.client_id), so in a full composition the application module applies before this one.

Entra ID is eventually consistent

Entra ID uses a primary/secondary replica architecture with asynchronous replication across geo-distributed datacenters; the model is one of eventual consistency. A freshly created SP may take seconds to replicate before child writes (credentials, grants, assignments) resolve, and Microsoft explicitly notes replication delays for app-role assignments recently granted or removed. The provider retries internally; transient 404 / "no matching record" errors on first apply usually clear on re-run. Application-only (app token) flows do not get session consistency β€” only delegated (app+user) flows do.


🧱 Design Principles

  • Type is the contract β€” deeply-typed object schemas, optional defaults, validation {} for every closed value set (preferred_single_sign_on_mode, cert type/encoding, tags↔feature_tags, CN= prefix). Malformed input fails at plan time, not at the API.
  • Keystone + granular children β€” one this SP; every credential/grant is its own resource, isolating drift and rotation.
  • Secure by default β€” app_role_assignment_required = true, 1-year forced credential expiry, owners explicit, admin-consent grants opt-in.
  • Write-once secrets stay sensitive β€” credential values only ever leave via sensitive outputs.
  • for_each over stable keys, never count β€” stable state addresses.
  • No display_name input, no resource_group_name β€” the SP derives its name from the app and is tenant-scoped.

πŸš€ Runbook

cd C:\GitHubCode\newazureadmodules\terraform-azuread-service-principal
terraform init -backend=false
terraform validate
terraform fmt -check

⚠️ No plan / apply offline. azuread modules require live tenant credentials (tenant_id, client_id, and a client secret or certificate) plus the Graph API permissions above. The offline gate (init -backend=false + validate + fmt -check) confirms structural correctness; run plan/apply only against a non-production tenant with a properly permissioned SP. The linked application must already exist (or be applied in the same run) because var.client_id references it.


πŸ§ͺ Testing

  • Offline: terraform validate and terraform fmt -check must pass with zero diffs (CI gate).
  • Live (non-prod tenant): apply the Minimal example against an existing app, then layer in a password, a certificate, and an owner; confirm the sensitive passwords output is populated once and that a later refresh shows an empty value (expected β€” write-only).
  • Negative: set preferred_single_sign_on_mode = "SAML" (wrong case), certificates[*].type = "x509", or both tags and feature_tags and confirm the validation {} blocks reject at plan time. Set token_signing_certificate.display_name = "portal" (no CN=) and confirm rejection.

πŸ’¬ Example Output

Outputs:

object_id = "9d44a0f2-...-7b21"
client_id = "b1e9...c0de"
display_name = "casey-loan-origination-api"
type = "Application"
tenant_id = "72f988bf-...-2d7cd011db47"
current_object_id = "0a7b...f31c"
password_key_ids = { "primary" = "a93c...0011" }
app_role_assignment_ids = { "graph_user_read_all" = "k3jf...Zx9" }
passwords = (sensitive value)
token_signing_certificate = {
 "key_id" = "5f2b...88a1"
 "thumbprint" = "A1B2C3D4E5F6..."
 "start_date" = "2026-06-18T00:00:00Z"
 "end_date" = "2027-06-18T00:00:00Z"
}

πŸ” Troubleshooting

Symptom Cause Fix
Insufficient privileges to complete the operation / 403 on apply Terraform SP lacks Application.ReadWrite.OwnedBy/.All (or admin consent not granted) Grant the application permission and admin-consent it; for user principals, assign Application Administrator.
SP can create the principal but fails to manage it on the next run SP authorized via Application.ReadWrite.OwnedBy but not in owners Add current_object_id to var.owners.
403 only on app-role assignments Missing AppRoleAssignment.ReadWrite.All (+ Application.Read.All/Directory.Read.All) Add the permission and admin-consent it.
403 only on delegated grants Missing DelegatedPermissionGrant.ReadWrite.All Add the permission and admin-consent it.
403 only on claims-mapping assignments Missing Policy.ReadWrite.ApplicationConfiguration + Policy.Read.All Add both permissions and admin-consent them.
App-role assignment transiently 404 / not found right after create Entra eventual consistency β€” Microsoft documents replication delays for recently granted/removed app-role assignments Re-run apply; the provider retries internally. Expected, not a module bug.
Child credentials/grants 404 immediately after SP creation SP not yet replicated across secondary replicas Re-run apply; add retry/back-off in pipelines.
AADSTS7000215: Invalid client secret provided Secret value mis-copied/truncated, or used against the wrong app Re-read from the sensitive passwords output; confirm client_id/tenant_id match.
AADSTS7000222:... client secret keys... are expired Credential past end_date Rotate via rotate_when_changed (Example 11) and update the consumer.
Plan wants to recreate a credential/grant you only renamed You changed the map key Map keys are state addresses β€” renaming = destroy/recreate. Keep keys stable.
Invalid for_each argument: sensitive value if you copy the cert pattern Iterating a sensitive collection directly Use toset(nonsensitive(keys(var.certificates))) (as the module does).
Cannot configure both 'tags' and 'feature_tags' (or the module's validation error) Both supplied Set at most one β€” they're mutually exclusive.
preferred_single_sign_on_mode / cert type / CN= validation error Value outside the closed set, or display name missing CN= Use a valid enum value; prefix the token-signing cert display name with CN=.
Delegated grant unexpectedly consents for everyone user_object_id omitted β†’ AllPrincipals (tenant-wide) Set user_object_id to scope to a single user, or accept admin consent intentionally.
Token signing certificate stopped working Certificate expired Rotate before end_date; use notification_email_addresses for advance warning.
Can't set the SP's display_name It is derived from the linked application Change the application's display name (in terraform-azuread-application); it propagates to the SP.
Destroy "fails" on a first-party Microsoft SP use_existing = true semantics Expected β€” with use_existing, delete is attempted but does not error for first-party apps.

πŸ”— Related Docs


πŸ’™ "Infrastructure as Code should be standardized, consistent, and secure."

Releases

Packages

Contributors

Languages