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.
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 viaclient_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 keyedfor_eachcollection, with a forced 1-year default expiry. - π Certificate credentials (
azuread_service_principal_certificate.this) β afor_eachcollection; the whole input issensitivebecause each entry carries credentialvalue. - π·οΈ 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.
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!
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
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
βΉοΈ
data.azuread_client_config.current,data.azuread_service_principal.lookup, anddata.azuread_service_principals.lookupare intentionally not wired into the diagram above β permain.tfthey are standalone read-only lookups (feeding only thecurrent_object_id/tenant_id/*_lookupsoutputs) 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).
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.currentworks without module-level provider config.
βΉοΈ
hashicorp/timedependency: the azuread provider deprecated the nativeend_date_relativefield onazuread_service_principal_passwordandazuread_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 viatime_staticand renders it into an absoluteend_datewithtimeadd. The anchor is captured once at create, so there is no plan drift. The module's publicend_date_relativeinput is unchanged.
β οΈ v3.x naming & derivation: the SP links to its application viaclient_id(not the legacyapplication_id). A service principal has no settabledisplay_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).
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 (omittinguser_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 beforeapply.
βΉοΈ 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.)
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
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_idworks because the value comes from theazuread_client_configdata source (known at plan time), not from the SP resource.
| 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) |
| 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 |
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_addressesis 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.certificatesis markedsensitive = truebecause each entry carries credentialvalue. The module iterates its keys withnonsensitivesince 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. Usenotification_email_addressesfor expiry warnings and rotate beforeend_date. Azure AD generates the key pair β you supply no certificate material. The PEM body is available (sensitive) asmodule.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)
}
}
}
β οΈ Omittinguser_object_idgrants tenant-wide admin consent (consentType = AllPrincipals) β it takes effect immediately and is not subject to review. To consent for a single user instead, setuser_object_id. The module always anchors the grant'sservice_principal_object_idto 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_idat its default00000000-0000-0000-0000-000000000000for "default access" (no specific role). Supply at least one ofprincipal_object_id/resource_object_idper 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_idto 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_tagsandtagsare mutually exclusive β avalidation {}block rejects setting both, and the module forcestags = nullwheneverfeature_tagsis set. Use rawtags = ["WindowsAzureActiveDirectoryIntegratedApp",...]only when you need a tag valuefeature_tagscan'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_changedforces Terraform to mint a new secret. Pair with thehashicorp/timeprovider (time_rotating) for automatic time-based rotation. Capture the new value from the sensitivepasswordsoutput 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
]
}βΉοΈ
ownersaccepts any mix of user, group, and service-principal object IDs. Always include the Terraform SP's own object ID β here viadata.azuread_client_config.current.object_id(equivalent to this module's owncurrent_object_idoutput) β 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
}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)
})| 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 |
π
passwordsandtoken_signing_certificate_valueare the sensitive outputs. A passwordvalueis 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.
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, notsecret-1). countis deliberately avoided β list indices shift on insert/remove and cascade-recreate everything after the change.
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.
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.
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.
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.
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 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.
- Type is the contract β deeply-typed
objectschemas,optionaldefaults,validation {}for every closed value set (preferred_single_sign_on_mode, certtype/encoding,tagsβfeature_tags,CN=prefix). Malformed input fails at plan time, not at the API. - Keystone + granular children β one
thisSP; 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_eachover stable keys, nevercountβ stable state addresses.- No
display_nameinput, noresource_group_nameβ the SP derives its name from the app and is tenant-scoped.
cd C:\GitHubCode\newazureadmodules\terraform-azuread-service-principal
terraform init -backend=false
terraform validate
terraform fmt -check
β οΈ Noplan/applyoffline. 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; runplan/applyonly against a non-production tenant with a properly permissioned SP. The linked application must already exist (or be applied in the same run) becausevar.client_idreferences it.
- Offline:
terraform validateandterraform fmt -checkmust 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
passwordsoutput is populated once and that a laterrefreshshows an emptyvalue(expected β write-only). - Negative: set
preferred_single_sign_on_mode = "SAML"(wrong case),certificates[*].type = "x509", or bothtagsandfeature_tagsand confirm thevalidation {}blocks reject at plan time. Settoken_signing_certificate.display_name = "portal"(noCN=) and confirm rejection.
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"
}
| 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. |
- Terraform Registry β
azuread_service_principaland theazuread_service_principal_*child resources - Terraform Registry β
azuread_app_role_assignmentΒ·azuread_service_principal_delegated_permission_grant - Microsoft Learn β Application and service principal objects in Microsoft Entra ID
- Microsoft Learn β Securing service principals in Microsoft Entra ID
- Microsoft Learn β Grant tenant-wide admin consent to an application
- Microsoft Learn β appRoleAssignment resource type (replication-delay note) and oAuth2PermissionGrant
- Microsoft Learn β What is the Microsoft Entra architecture? (replication / eventual consistency)
SCOPE.mdβ cross-module contract and Graph API permissions for this module
π "Infrastructure as Code should be standardized, consistent, and secure."