Skip to content

Latest commit

Β 

History

1 Commit

Folders and files

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

Repository files navigation

☁️ Azure Notification Hub Terraform Module

One application's push endpoint inside a Notification Hubs namespace (azurerm_notification_hub). Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources Caveat

🧩 Overview

  • πŸ“² A hub holds one application's push credentials. The namespace above it holds one purpose; the hub maps to one app.
  • πŸ”΄ Android push cannot be configured through this resource. gcm_credential carries the FCM legacy key, which Google retired on 20 June 2024 β€” and this provider exposes no FCM v1 arguments at all.
  • πŸ”΄ Only three of Azure's eight push platforms are here. WNS, ADM and Xiaomi are current and absent; MPNS and Baidu are retired platforms, so no loss.
  • πŸ”΄ Removing a credential block replaces the hub, per an Azure SDK bug the provider documents β€” destroying every device registration. Editing one is an in-place update.
  • ⚠️ browser_credential is force-new on any change, not just removal. Three blocks, three lifecycles.
  • ⚠️ apns_credential.application_mode = "Sandbox" fails silently for production device tokens.
  • πŸ”’ Every credential is in Terraform state in plaintext. sensitive redacts plan output, not state.
  • πŸ”’ No credential value is emitted β€” presence only.
  • βœ… tags are supported here, unlike on the authorization rules beneath.

πŸ’‘ Why it matters: this resource looks like a tidy list of three optional credential blocks. In fact one of them targets a dead API, the three have three different replacement behaviours, and the whole set lands in state in plaintext. The module's job is to make each of those legible before an apply rather than after an outage.

❀️ Support this project

If this module saves you time, please consider supporting its continued development:


πŸ—ΊοΈ Where this fits in the family

flowchart TB
  ns["terraform-azurerm-notification-hub-namespace. Pass its NAME, resource_group_name and location - a hub needs three separate name arguments and there is no single parent ID. The namespace must be of type NotificationHub, not the legacy Messaging, and that is force-new there."]
  hub["terraform-azurerm-notification-hub"]
  rule["terraform-azurerm-notification-hub-authorization-rule takes this hub's NAME plus the namespace and resource group. It is where the SHARED ACCESS KEYS are minted - this module holds push credentials, that one holds client credentials."]
  apns["APPLE, via apns_credential. Token-based. application_mode picks the gateway, and Sandbox fails SILENTLY for production device tokens."]
  web["BROWSERS, via browser_credential. A VAPID pair - the public half is not a secret and the provider agrees. But the whole block is FORCE-NEW, so rotating the pair rebuilds the hub."]
  fcm["ANDROID: gcm_credential carries the FCM LEGACY api_key, and Google RETIRED that API on 20 June 2024. FCM v1 needs a private key, a client email and a project ID, and this provider exposes NONE of them - so Android push must be configured out of band, outside Terraform's view."]
  gap["AND WNS, ADM AND XIAOMI ARE ABSENT TOO. ARM has eight credential kinds; this provider has three. MPNS and Baidu are retired so no loss, but those three are current."]

  ns -->|"name, resource_group_name, location"| hub
  hub -->|"name"| rule
  hub -->|"configures"| apns
  hub -->|"configures"| web
  hub -->|"cannot usefully configure"| fcm
  hub -->|"cannot configure at all"| gap

  classDef mine fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class hub mine;
  class fcm,gap keystone;
  class ns,rule,apns,web sib;
Loading

🧬 What this module builds

flowchart TB
  three["THREE CREDENTIAL BLOCKS, THREE DIFFERENT LIFECYCLES - which no reader would guess from three similar-looking optional blocks. Editing any credential is an IN-PLACE update. REMOVING apns_credential or gcm_credential FORCES RECREATION, per an Azure SDK for Go bug the provider documents. And browser_credential is force-new on ANY change."]
  contagion["THE SECRETS ARE WRAPPED WITH sensitive() AT POINT OF USE, not by marking the variables sensitive - and the reason is NOT the for_each reason used elsewhere in this library, since these are single blocks. It is CONTAGION: a sensitive object would make application_mode and vapid_public_key sensitive too, and Terraform refuses to emit a sensitive bool, so the derived flags below would break."]
  pubkey["vapid_public_key is deliberately NOT wrapped. The provider does not mark it sensitive, because a VAPID public key exists to be handed to browsers. This module follows the provider's own split rather than redacting both halves of a pair."]
  probable["The validations catch DOCUMENTED PROBABLE MISTAKES: a token pasted WITH its PEM markers when the provider says to send the contents between them; a filename instead of a key; a PEM block where a base64url VAPID key belongs; identical key halves. Two further checks are HEURISTICS and say so in their own messages."]
  sandbox["apns_targets_the_sandbox_gateway: derived, and it exists because the failure is SILENT. Production device tokens are not valid on Apple's development gateway, so notifications are simply not delivered - an outage that reads like a client bug."]
  retired["configures_the_retired_fcm_legacy_credential plus fcm_v1_cannot_be_configured_through_this_provider: the retirement is REPORTED, not enforced. The provider accepts the block and Azure still stores it, so rejecting it would refuse legal input and break a caller mid-migration."]
  nostate["credentials_are_in_terraform_state_in_plaintext: always true, and it matters more than the sensitive markings. sensitive redacts plan output; it does not encrypt state. Treat anyone with state access as holding the push credentials for every registered device."]
  none["configures_no_push_platform: derived rather than enforced, because the provider documents no credential as required and a hub created ahead of its credentials is a legitimate intermediate state."]
  this["azurerm_notification_hub.this"]

  three -->|"lifecycle"| this
  contagion -->|"rendering"| this
  pubkey -->|"exception"| contagion
  probable -->|"validated"| this
  this -->|"emits"| sandbox
  this -->|"emits"| retired
  this -->|"emits"| nostate
  this -->|"emits"| none

  classDef mine fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class this mine;
  class contagion mine;
  class retired,nostate keystone;
  class three,pubkey,probable,sandbox,none sib;
Loading

Resource inventory

Resource Count Notes
azurerm_notification_hub.this 1 The keystone.
apns_credential 0..1 iOS/macOS. Editable in place; removal replaces the hub.
browser_credential 0..1 Web Push. Force-new on any change.
gcm_credential 0..1 πŸ”΄ FCM legacy β€” retired API.
timeouts 0..1 All four; raise delete.

βœ… Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Azure resource type Microsoft.NotificationHubs/namespaces/notificationHubs
Provider block None in this module. The caller configures provider "azurerm", including the mandatory features {} block, and supplies authentication.

Schema notes that bite β€” confirmed against the live provider schema and Microsoft's documentation:

  • πŸ”΄ gcm_credential is FCM legacy, retired 20 June 2024 (example 5).
  • πŸ”΄ No FCM v1 arguments exist in this provider (example 5).
  • πŸ”΄ Removing apns_credential or gcm_credential forces recreation (example 7).
  • ⚠️ browser_credential is force-new on any change (examples 4, 7).
  • ⚠️ application_mode = "Sandbox" is Apple's dev gateway (example 3).
  • ⚠️ token is the .p8 contents, without the PEM markers (example 3).
  • ⚠️ vapid_public_key is NOT provider-marked sensitive; the private key is (example 4).
  • ⚠️ Force-new: name, namespace_name, resource_group_name, location (example 8).
  • βœ… tags are supported (example 8).
  • lifecycle is not valid inside a module block (example 11).

πŸ”‘ Required Azure RBAC Roles / Permissions

Operation Role Scope
Create, update or delete the hub Contributor the namespace
Read it Reader the hub
πŸ”’ Read the push credentials this module sets whatever grants state access the Terraform backend, not Azure
πŸ”΄ Send notifications a shared access key on an authorization rule the hub

πŸ”’ The third row is not an Azure permission, and that is the point. The APNS signing key, the VAPID private key and the FCM key are all in Terraform state in plaintext (example 9). So the effective access control on those secrets is whoever can read the state backend β€” which is frequently a wider group than whoever holds Contributor on the namespace.

πŸ”΄ The fourth row is a different resource entirely. The credentials this module holds let Azure talk to Apple and Google; the credentials that let your application talk to Azure come from an authorization rule.

Azure Prerequisites

  • πŸ”΄ A namespace of type NotificationHub, not the legacy Messaging β€” and that is force-new on the namespace (example 1).
  • πŸ”΄ An FCM v1 configuration applied out of band, if Android is a target platform (example 5).
  • πŸ”’ An encrypted, access-controlled state backend before any credential is set (example 9).
  • ⚠️ The APNS key contents, not the .p8 file path (example 3).
  • ⚠️ A VAPID key pair, if Web Push is a target (example 4).

πŸ“ Module Structure

terraform-azurerm-notification-hub/
β”œβ”€β”€ providers.tf   # required_version + the pinned azurerm provider. No provider block.
β”œβ”€β”€ variables.tf   # name, namespace_name, resource_group_name, location,
#                  # apns_credential, browser_credential, gcm_credential, tags, timeouts
β”œβ”€β”€ main.tf        # the keystone `this`; sensitive() at point of use
β”œβ”€β”€ outputs.tf     # id first, then identity, presence flags, and five constants
β”œβ”€β”€ README.md      # this document
β”œβ”€β”€ SCOPE.md       # the cross-module contract
β”œβ”€β”€ LICENSE        # MIT
└── .gitignore

βš™οΈ Quick Start

provider "azurerm" {
  features {}
}

module "notification_hub" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-notification-hub.git?ref=v1.0.0"

  name = "nh-exampleapp"

  # ⚠️ Three NAMES, not an ID -- source all three from the namespace module.
  namespace_name      = module.notification_hub_namespace.name
  resource_group_name = module.notification_hub_namespace.resource_group_name
  location            = module.notification_hub_namespace.location

  # πŸ”’ A real secret, and it lands in state in plaintext (example 9).
  apns_credential = {
    application_mode = "Production" # ⚠️ not "Sandbox" (example 3)
    bundle_id        = "com.contoso.exampleapp"
    key_id           = var.apns_key_id
    team_id          = var.apple_team_id
    token            = var.apns_signing_key # the .p8 CONTENTS, no PEM markers
  }

  tags = { environment = "prod", workload = "push" }
}

πŸ”΄ Android is deliberately absent from that call. gcm_credential would configure a retired API; FCM v1 has to be set outside Terraform (example 5).

ℹ️ The caller configures the provider, its authentication, and the mandatory features {} block. This module declares none of them.

πŸ”Œ Cross-Module Contract

Consumes

Input Type Source module
name string caller β€” force-new
namespace_name string terraform-azurerm-notification-hub-namespace output name
resource_group_name string that module's resource_group_name β€” locates the namespace
location string that module's location β€” should match it
apns_credential object(...) πŸ”’ caller β€” a real secret
browser_credential object(...) πŸ”’ caller β€” force-new on any change
gcm_credential object(...) πŸ”΄ caller β€” a retired API
tags map(string) caller β€” βœ… supported
timeouts object(...) caller β€” raise delete

Emits

Output Description Consumed by
id The hub's Resource ID. role assignments, diagnostics
name πŸ”΄ What an authorization rule consumes. terraform-azurerm-notification-hub-authorization-rule
namespace_name / resource_group_name The other two names a rule needs. that module
location The hub's region. review
has_apns_credential / has_browser_credential / has_gcm_credential πŸ”’ Presence only. posture review
configures_no_push_platform ⚠️ Derived. Assert false. posture review
apns_targets_the_sandbox_gateway ⚠️ Derived. Assert false in production. delivery review
configures_the_retired_fcm_legacy_credential πŸ”΄ Derived. Assert false. platform review
fcm_v1_cannot_be_configured_through_this_provider πŸ”΄ Always true. design review
only_three_push_platforms_are_configurable_here πŸ”΄ Always true. design review
removing_a_credential_block_replaces_the_hub πŸ”΄ Always true. change review
credentials_are_in_terraform_state_in_plaintext πŸ”’ Always true. security review

πŸ“š Example Library

1 Β· The smallest real hub
module "notification_hub" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-notification-hub.git?ref=v1.0.0"

  name                = "nh-exampleapp"
  namespace_name      = module.notification_hub_namespace.name
  resource_group_name = module.notification_hub_namespace.resource_group_name
  location            = module.notification_hub_namespace.location
}

ℹ️ Four required arguments and no credentials β€” which is legal, and produces a hub that can register devices and deliver nothing:

output "check" { value = module.notification_hub.configures_no_push_platform } # ⚠️ expect false

βœ… That flag is derived rather than enforced, because the provider documents no credential as required and a hub created ahead of its credentials is a legitimate intermediate state. Rejecting it would refuse legal input.

⚠️ Note this resource needs THREE separate name arguments and there is no single parent ID. All three must agree, and this module cannot check that they describe the same object β€” so source all three from the namespace module rather than hard-coding any of them.

πŸ”΄ The namespace must be of type NotificationHub. The legacy Messaging type is not what a hub expects to live in, and the namespace's type is force-new β€” so that is a decision already made before this module runs, and one it cannot see.

2 Β· A hub, a namespace, and what each one owns
NAMESPACE   one purpose: dev / test / prod
            owns the SKU (the billed unit), the region, zone redundancy,
            disaster recovery, and tags
   |
   +-- HUB  one application
            owns the PUSH CREDENTIALS -- Azure's credentials for talking to
            Apple and Google. And tags.
        |
        +-- AUTHORIZATION RULE  one consumer
                owns the SHARED ACCESS KEYS -- your application's credentials
                for talking to Azure. No tags.

βœ… Two different kinds of credential, one layer apart, and they are easy to conflate. This module holds the keys Azure uses outbound to the push services. The authorization rule holds the keys your code uses inbound to Azure.

ℹ️ Tags reach the hub but not the rule, so a hub is the lowest level in this family a tag policy can govern (example 8).

⚠️ Deleting anything deletes everything below it, including device registrations β€” which client applications created at runtime, so they are not in Terraform state and nothing recreates them (example 11).

πŸ’‘ Microsoft's guidance is one hub per application, with related hubs grouped in a namespace per environment. A hub holds one credential per platform, so two apps sharing a hub would share Apple credentials.

3 · ⚠️ APNS: the gateway that fails silently, and the key that is not a file
apns_credential = {
  application_mode = "Production" # ⚠️ or "Sandbox" -- see below
  bundle_id        = "com.contoso.exampleapp"
  key_id           = "ABC123DEFG"
  team_id          = "HIJ456KLMN"
  token            = var.apns_signing_key
}

⚠️ Sandbox is Apple's development gateway, and choosing it wrongly fails silently. A production build's device tokens are not valid there, so notifications are simply not delivered β€” no rejection, no useful error. That reads as a client bug:

output "delivery_check" {
  value = module.notification_hub.apns_targets_the_sandbox_gateway # ⚠️ expect false in production
}

πŸ”΄ token is the CONTENTS of the .p8 key, without the PEM markers. The provider is explicit that it is the text between -----BEGIN PRIVATE KEY----- and -----END PRIVATE KEY-----. Pasting the whole file is the predictable mistake, so the module rejects it:

apns_credential.token must be the CONTENTS of the .p8 key, without the PEM markers -- the provider
documents it as the text BETWEEN the -----BEGIN PRIVATE KEY----- and -----END PRIVATE KEY----- lines.
Strip those lines and any surrounding blank lines.

⚠️ And a filename is rejected too, since token = "AuthKey_ABC123DEFG.p8" is the other way to get this wrong.

ℹ️ bundle_id is checked for a dot, and that check is a HEURISTIC β€” the message says so. A bundle ID is conventionally reverse-DNS, and a value without a dot is far more likely to be a team ID or a key ID pasted into the wrong field. The provider documents no format, so the module infers rather than asserts.

πŸ”’ The token is a real secret and it lands in state in plaintext (example 9).

4 · ⚠️ Web Push: a public key that is not a secret, and a block that is force-new
browser_credential = {
  subject           = "mailto:push-admin@contoso.com"
  vapid_public_key  = var.vapid_public_key  # published to browsers
  vapid_private_key = var.vapid_private_key # a real secret
}

βœ… The provider marks vapid_private_key sensitive and vapid_public_key not β€” and this module follows that split rather than redacting both. A VAPID public key exists to be handed to browsers; redacting it would break plan review while protecting nothing. That is the same reasoning this library applies to public keys generally, and here the provider reached it first.

πŸ”΄ But the whole block is force-new on ANY change, unlike the other two credentials. So rotating a VAPID key pair through Terraform destroys the hub and every device registration on it. Treat a rotation as a migration, not an edit (example 7).

⚠️ VAPID keys are base64url, not PEM. Pasting a .pem file is the predictable mistake and the module rejects it β€” and it also rejects identical public and private halves, since one of them is then wrong.

ℹ️ subject is a VAPID contact URI, conventionally mailto: or an https:// URL, so a push service can reach whoever operates the application. The provider documents it only as "the subject name of web push", so the module does not enforce a scheme β€” it flags a bare email address and says the check is a heuristic:

browser_credential.subject looks like a bare email address. A VAPID subject is conventionally a
contact URI -- "mailto:push-admin@contoso.com" rather than "push-admin@contoso.com". This check is a
HEURISTIC, not a documented provider rule ...
5 Β· πŸ”΄ Android: a retired API, and no replacement in this provider
Google stopped supporting FCM legacy HTTP on 20 JUNE 2024.
Microsoft: "As of June 2024, FCM legacy APIs are no longer supported and are retired."

gcm_credential { api_key = "..." }     <- the FCM LEGACY credential. One key.

FCM v1 -- the supported protocol -- needs THREE values:
    a private key, a client email, and a project ID

This provider version exposes NO fcm_v1_credential block and no equivalent arguments.

πŸ”΄ So Android push cannot be configured from Terraform at all:

output "gap" {
  value = module.notification_hub.fcm_v1_cannot_be_configured_through_this_provider # always true
}
output "assert_this" {
  value = module.notification_hub.configures_the_retired_fcm_legacy_credential # πŸ”΄ expect false
}

πŸ”΄ FCM v1 must be configured out of band β€” the Azure portal's Google (FCM v1) blade, the REST API, or an Azure SDK β€” and that configuration then sits outside Terraform's view. Expect no plan to show it and no drift detection on it.

βœ… The block is still exposed here, deliberately. The provider accepts it and Azure still stores it, so refusing it would reject input that is legal today and would break a caller mid-migration. This module reports the retirement rather than enforcing a judgement β€” the same report-don't-refuse position this library takes elsewhere.

⚠️ Note how this differs from a retired resource. Where a whole resource or family is the retired experience, this library declines to author it. Here the retirement is scoped to one optional argument of an otherwise current resource β€” iOS and Web Push both work β€” so the module ships and names the dead block.

πŸ’‘ Re-verify against current provider releases before designing around this. It is a gap that should eventually close.

6 Β· πŸ”΄ Five platforms this provider cannot configure
ARM's notification hub carries EIGHT credential kinds:
    apns  fcm  wns  adm  baidu  mpns  browser  xiaomi

This provider exposes THREE:
    apns_credential   browser_credential   gcm_credential

Missing, and RETIRED platforms -- no loss:
    mpns   (Windows Phone)
    baidu

Missing, and CURRENT:
    wns    (Windows)
    adm    (Amazon Fire)
    xiaomi

πŸ”΄ So a hub serving Windows, Amazon Fire or Xiaomi devices cannot be fully configured from Terraform, and needs the same out-of-band treatment as FCM v1:

output "coverage" {
  value = module.notification_hub.only_three_push_platforms_are_configurable_here # always true
}

βœ… Distinguishing the two groups is the useful part. MPNS and Baidu being absent is correct β€” they are retired platforms this library would refuse to author for anyway. WNS, ADM and Xiaomi being absent is a provider gap, and worth raising upstream rather than working around.

⚠️ This is emitted as a constant rather than derived because it is a property of the provider, not of your configuration β€” there is nothing in the plan that could reveal it.

πŸ’‘ Check the platform list before choosing Terraform as the configuration point for a multi-platform hub. Partial configuration across two tools is worse than one tool consistently.

7 Β· πŸ”΄ Three blocks, three lifecycles
EDITING a credential            -> in-place update            βœ…
REMOVING apns_credential        -> FORCES RECREATION          πŸ”΄
REMOVING gcm_credential         -> FORCES RECREATION          πŸ”΄
ANY change to browser_credential -> FORCES RECREATION          πŸ”΄

And recreating the hub destroys every DEVICE REGISTRATION on it.

πŸ”΄ The removal behaviour is an upstream bug, not a design. The provider documents it against an Azure SDK for Go issue β€” so it may be fixed, and it should be re-checked rather than assumed permanent:

output "change_review" {
  value = module.notification_hub.removing_a_credential_block_replaces_the_hub # always true
}

⚠️ This asymmetry is what no reader would guess from three similar-looking optional blocks. Adding Apple credentials is safe; taking them away is an outage. And browser_credential does not even need to be removed β€” changing it is enough.

πŸ”΄ Device registrations are the loss that matters. Client applications create them at runtime, so they are not in Terraform state and nothing recreates them. Every installed app has to re-register.

πŸ’‘ If a credential must go away, plan it as a migration: stand up a new hub, move clients, retire the old one. Do not achieve it by deleting a block from HCL.

8 Β· Force-new, and where tags reach
πŸ”΄ Force-new -- each replaces the hub, destroying device registrations:
     name
     namespace_name
     resource_group_name
     location

βœ… Updates in place:
     tags
     the CONTENTS of apns_credential and gcm_credential (but not their removal)

βœ… tags are supported here and not on the authorization rules beneath, so a hub is the lowest level in this family an Azure Policy requiring tags can reach. Tag the namespace too β€” it is the billed unit.

⚠️ location should match the namespace's own region. The provider even describes it as the region "in which this Notification Hub Namespace exists" β€” a hub is not independently placeable, and this module cannot read the namespace to check.

⚠️ Renaming a hub is not a rename. It is a new hub, and the old registrations do not follow.

πŸ’‘ Source all three of namespace_name, resource_group_name and location from the namespace module. Three independent literals are three chances to disagree, and the failure is a not-found at apply rather than a validation error.

9 Β· πŸ”’ Every credential is in state, in plaintext
# All three of these are real secrets:
apns_credential.token
browser_credential.vapid_private_key
gcm_credential.api_key

# All three are provider-marked sensitive, and wrapped with sensitive() at point of use.
# ALL THREE ARE STILL IN TERRAFORM STATE IN PLAINTEXT.

πŸ”’ sensitive = true redacts plan and apply output. It does not encrypt state. So the control that actually protects these values is an encrypted, access-controlled remote backend β€” never a local state file in a repository:

output "read_this" {
  value = module.notification_hub.credentials_are_in_terraform_state_in_plaintext # always true
}

πŸ”΄ Treat anyone with state access as holding the push credentials for every device your application has registered. That is a wider group than whoever holds Contributor on the namespace, which is why the permissions table has a row that is not an Azure permission at all.

βœ… No credential value is emitted by this module β€” only presence:

output "platforms" {
  value = {
    apple   = module.notification_hub.has_apns_credential
    browser = module.notification_hub.has_browser_credential
    android = module.notification_hub.has_gcm_credential # πŸ”΄ expect false (example 5)
  }
}

βœ… Presence-only is deliberate. Re-emitting a secret already in this module's state would copy it into every consuming configuration's state as well, widening the blast radius for no benefit. A consumer needing the key should read it from the same secret store this module read it from.

πŸ’‘ Source every credential from a secret store, not from a committed .tfvars. The variables are not marked sensitive (see the Inputs section for why), so a value in a variable file is plainly visible.

10 Β· Why the credential variables are not marked `sensitive`
# variables.tf -- NOT marked sensitive:
variable "apns_credential" {
  type = object({
    application_mode = string
    # ... token = string
  })
}

# main.tf -- sensitive() applied to the secret FIELD, at the point of use:
token = sensitive(apns_credential.value.token)

⚠️ The reason is contagion, and it is not the reason that applies elsewhere in this library. Elsewhere a collection is left unmarked because a sensitive value cannot be a for_each argument. These are single blocks, so that does not apply β€” a sensitive variable would render perfectly well.

πŸ”΄ The problem is that sensitivity spreads to the whole object. Marking apns_credential sensitive would make application_mode sensitive too, so apns_targets_the_sandbox_gateway would become a sensitive bool β€” and Terraform refuses to emit one. The same applies to vapid_public_key, which is not a secret at all.

βœ… So the secret fields are wrapped individually, at the point of use β€” which the provider already marks sensitive in its schema anyway, making this belt-and-braces rather than the only protection.

⚠️ The trade-off is honest and worth stating: a value passed in a .tfvars file is not redacted at the variable level. That is why the guidance is to source credentials from a secret store rather than a variable file, and why example 9 exists.

ℹ️ This is the second distinct reason this library has for not marking a variable sensitive. Both end in the same technique β€” sensitive() at point of use β€” for different causes.

11 Β· Destroy, locks and importing
terraform import 'module.notification_hub.azurerm_notification_hub.this' \
  "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-push-prod/providers/Microsoft.NotificationHubs/namespaces/nhn-push-prod/notificationHubs/nh-exampleapp"

πŸ”΄ Destroying the hub destroys every device registration on it. Those were created by client applications at runtime, so they are not in state and nothing recreates them β€” every installed app has to re-register. Raise the delete timeout and mean it.

⚠️ An import will not bring the credentials with it meaningfully. The secret fields are write-only in effect: Azure does not return them, so an imported hub shows a diff proposing to set them, and the values must come from wherever they were originally stored.

⚠️ prevent_destroy is not available, because lifecycle is not valid inside a module block. A CanNotDelete management lock on the namespace is the available protection β€” and it prevents deletion, not the force-new replacements of examples 7 and 8.

βœ… Passing namespace_name as an attribute orders the destroy β€” the hub goes before the namespace, and the authorization rules go before the hub.

πŸ’‘ On anything user-facing, the lock is worth the inconvenience, precisely because the loss is not recoverable from Terraform state.

12 Β· Hubs per application from one map
module "hubs" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-notification-hub.git?ref=v1.0.0"

  for_each = {
    consumer = "com.contoso.consumerapp"
    field    = "com.contoso.fieldapp"
  }

  name                = "nh-${each.key}"
  namespace_name      = module.notification_hub_namespace.name
  resource_group_name = module.notification_hub_namespace.resource_group_name
  location            = module.notification_hub_namespace.location

  apns_credential = {
    application_mode = "Production"
    bundle_id        = each.value # a different Apple app per hub
    key_id           = var.apns_key_id
    team_id          = var.apple_team_id
    token            = var.apns_signing_key
  }

  tags = { environment = "prod", app = each.key }
}

βœ… for_each over stable application keys, never count, so removing one app never re-indexes the rest β€” which matters more than usual here, because name is force-new and a re-index would destroy hubs and their registrations.

βœ… One hub per application is the point of the map. A hub holds one credential per platform, so two apps in one hub would share a bundle_id β€” which is why bundle_id varies per entry above while the signing key does not.

ℹ️ The APNS key and team are shared because they belong to the Apple developer team, not to an individual app. Only the bundle ID distinguishes the apps.

πŸ”΄ Every hub in that map inherits the same platform gap (examples 5, 6). The map changes how many apps you serve, not which platforms you can configure.

13 Β· πŸ—οΈ End-to-end composition
provider "azurerm" {
  features {}
}

module "rg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"

  name     = "rg-push-prod"
  location = "eastus2"

  tags = { environment = "prod", workload = "push" }
}

# ── The namespace: the billed unit, and the resilience decisions ─────────────
module "notification_hub_namespace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-notification-hub-namespace.git?ref=v1.0.0"

  name                = "nhn-push-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location

  # πŸ”΄ Must be NotificationHub, not the legacy Messaging -- and it is force-new there.
  namespace_type = "NotificationHub"
  sku_name       = "Standard"

  tags = { environment = "prod", workload = "push" }
}

# ── The hub: Azure's credentials for talking to the push services ────────────
module "notification_hub" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-notification-hub.git?ref=v1.0.0"

  name = "nh-exampleapp"

  # ⚠️ Three names, all from the namespace module so they cannot disagree (example 8).
  namespace_name      = module.notification_hub_namespace.name
  resource_group_name = module.notification_hub_namespace.resource_group_name
  location            = module.notification_hub_namespace.location

  # πŸ”’ From a secret store, never a committed tfvars (examples 9, 10).
  # ⚠️ Production, not Sandbox -- Sandbox fails silently (example 3).
  apns_credential = {
    application_mode = "Production"
    bundle_id        = "com.contoso.exampleapp"
    key_id           = var.apns_key_id
    team_id          = var.apple_team_id
    token            = var.apns_signing_key # the .p8 CONTENTS, no PEM markers
  }

  # πŸ”’ The public half is not a secret and is not redacted; the private half is.
  # ⚠️ This whole block is force-new, so a VAPID rotation is a migration (example 4).
  browser_credential = {
    subject           = "mailto:push-admin@contoso.com"
    vapid_public_key  = var.vapid_public_key
    vapid_private_key = var.vapid_private_key
  }

  # πŸ”΄ gcm_credential deliberately OMITTED. It configures the FCM legacy API, which
  #    Google retired on 20 June 2024, and this provider has no FCM v1 arguments --
  #    so Android push is configured out of band, in the portal's Google (FCM v1)
  #    blade, and lives outside Terraform's view (example 5).

  tags = { environment = "prod", workload = "push", app = "exampleapp" }

  timeouts = { delete = "60m" } # registrations do not come back (example 11)
}

output "hub_posture" {
  value = {
    id   = module.notification_hub.id
    name = module.notification_hub.name

    # πŸ”’ Presence only -- no credential is emitted (example 9):
    platforms = {
      apple   = module.notification_hub.has_apns_credential
      browser = module.notification_hub.has_browser_credential
      android = module.notification_hub.has_gcm_credential
    }

    # βœ… Assertions worth automating:
    no_platform     = module.notification_hub.configures_no_push_platform                 # expect false
    apple_sandbox   = module.notification_hub.apns_targets_the_sandbox_gateway              # expect false
    retired_android = module.notification_hub.configures_the_retired_fcm_legacy_credential  # expect false

    # πŸ”΄ Runbook facts, not statuses:
    android_gap    = module.notification_hub.fcm_v1_cannot_be_configured_through_this_provider
    platform_gap   = module.notification_hub.only_three_push_platforms_are_configurable_here
    removal_note   = module.notification_hub.removing_a_credential_block_replaces_the_hub
    state_warning  = module.notification_hub.credentials_are_in_terraform_state_in_plaintext
  }
}

βœ… What the composition gets right: all three location arguments sourced from the namespace module so they cannot disagree, Production chosen explicitly because Sandbox fails silently, credentials from variables a pipeline populates from a secret store, gcm_credential omitted with a comment explaining why rather than silently absent, tags on the hub and the namespace, a raised delete timeout, and the three silent-failure assertions exported for CI.

πŸ”΄ What still needs a human: an encrypted state backend before any of this is applied (example 9); the FCM v1 configuration in the portal, plus a note somewhere durable that it is not managed by Terraform (example 5); and the authorization rules that give the application its own credentials β€” a separate module, which is where the shared access keys live (example 2).

⚠️ Note the composition creates no authorization rule. That is deliberate: the hub holds Azure's outbound credentials, and the rule holds the application's inbound ones β€” two different concerns, one layer apart.

πŸ“₯ Inputs

Input Type Default Notes
name string β€” Required. Force-new.
namespace_name string β€” Required. Force-new. A name.
resource_group_name string β€” Required. Force-new. Locates the namespace.
location string β€” Required. Force-new. Should match the namespace.
apns_credential object(...) null πŸ”’ iOS/macOS. Removal replaces the hub.
browser_credential object(...) null πŸ”’ Web Push. Force-new on any change.
gcm_credential object(...) null πŸ”΄ FCM legacy β€” retired API.
tags map(string) {} βœ… Supported.
timeouts object({ create, read, update, delete }) null Raise delete.

πŸ”’ None of the credential variables is marked sensitive, deliberately β€” see example 10. The secret fields are wrapped with sensitive() at the point of use in main.tf, and the provider marks them sensitive in its own schema.

Full schemas
variable "apns_credential" {
  type = object({
    application_mode = string # Production | Sandbox -- Sandbox fails SILENTLY for prod tokens
    bundle_id        = string
    key_id           = string
    team_id          = string
    token            = string # the .p8 CONTENTS, WITHOUT the PEM markers
  })
  default = null

  # The documented probable mistake. strcontains(), not a regex -- these are literals.
  validation {
    condition = var.apns_credential == null ? true : !(
      strcontains(var.apns_credential.token, "BEGIN PRIVATE KEY") ||
      strcontains(var.apns_credential.token, "END PRIVATE KEY")
    )
    error_message = "apns_credential.token must be the CONTENTS of the .p8 key, without the PEM markers ..."
  }

  # A HEURISTIC, and the message says so.
  validation {
    condition     = var.apns_credential == null ? true : strcontains(var.apns_credential.bundle_id, ".")
    error_message = "apns_credential.bundle_id does not contain a dot, which suggests a team ID or a key ID was passed instead ... This check is a HEURISTIC, not a documented rule ..."
  }
}

variable "gcm_credential" {
  type    = object({ api_key = string })
  default = null
  # Reported, not refused: the provider accepts it and Azure still stores it, so rejecting
  # it would refuse legal input and break a caller mid-migration. See example 5.
}

🧾 Outputs

Output Description Sensitive
id The hub's Resource ID. no
name / namespace_name / resource_group_name / location Identity. All force-new. no
has_apns_credential / has_browser_credential / has_gcm_credential πŸ”’ Presence only. no
configures_no_push_platform ⚠️ Derived. no
apns_targets_the_sandbox_gateway ⚠️ Derived. no
configures_the_retired_fcm_legacy_credential πŸ”΄ Derived. no
fcm_v1_cannot_be_configured_through_this_provider πŸ”΄ Always true. no
only_three_push_platforms_are_configurable_here πŸ”΄ Always true. no
removing_a_credential_block_replaces_the_hub πŸ”΄ Always true. no
credentials_are_in_terraform_state_in_plaintext πŸ”’ Always true. no

πŸ”’ No output is sensitive, because no credential is emitted. Presence flags only β€” see example 9 for why re-emitting a secret already in state is the wrong trade.

🧠 Architecture Notes

  • Three optional credential blocks that look interchangeable have three different lifecycles and one dead target. That is the whole shape of this resource, and none of it is visible from the argument list β€” which is why five of the module's outputs are constants rather than values.

  • The FCM finding is a retirement scoped to an ARGUMENT, not to a resource or a family. This library has refused whole families before on retirement grounds. Here azurerm_notification_hub is current and actively useful β€” iOS and Web Push both work β€” while one optional block targets an API Google removed on 20 June 2024, and the provider offers no replacement. The right response is to author the module and report the dead block, not to refuse.

  • And the block is rendered rather than rejected, because the provider accepts it and Azure still stores it. Rejecting it would refuse input that is legal today and would break a caller part-way through a migration β€” the report-don't-refuse position this library takes whenever enforcement would reject legal input.

  • fcm_v1_cannot_be_configured_through_this_provider matters more than the retirement flag. The retirement is a fact about Google; this is a fact about what a caller can accomplish here. Android push has to be configured out of band, and that configuration then has no plan, no drift detection and no record in Terraform.

  • The platform-coverage constant distinguishes absent-and-retired from absent-and-current. MPNS and Baidu missing is correct. WNS, ADM and Xiaomi missing is a provider gap worth raising upstream. Collapsing the two would lose the actionable half.

  • removing_a_credential_block_replaces_the_hub describes an upstream bug, and says so. The provider documents it against an Azure SDK for Go issue, so it may be fixed β€” the output tells a reader to re-check rather than treating it as permanent design. The asymmetry it creates is the useful part: edit freely, remove deliberately.

  • apns_targets_the_sandbox_gateway exists because the failure is silent. Production device tokens are not valid on Apple's development gateway, so notifications are simply not delivered β€” an outage that reads like a client bug. A derived flag turns it into something a reviewer or a CI check can see.

  • The credential variables are deliberately not marked sensitive, for a reason distinct from the usual one. Elsewhere in this library a collection stays unmarked because a sensitive value cannot be a for_each argument; these are single blocks, so that does not apply. The reason here is contagion: marking the object would make application_mode and vapid_public_key sensitive too, and a derived flag built from a sensitive value is a sensitive bool Terraform refuses to emit. So sensitive() is applied to the secret fields at the point of use β€” and the trade-off, that a .tfvars value is not redacted, is stated rather than hidden.

  • vapid_public_key is left unwrapped because the provider does not mark it sensitive, and it is right not to: a VAPID public key exists to be handed to browsers. This library reached the same conclusion about public keys independently; here the provider got there first, and following its split is better than second-guessing it.

  • credentials_are_in_terraform_state_in_plaintext is the caveat that outranks the markings. Every credential here is in state in the clear, so the effective access control is the state backend β€” which is why the permissions table carries a row that is not an Azure permission at all.

  • Two validations are heuristics and say so in their own messages. A bundle ID without a dot, and a VAPID subject that is a bare email address, are both inferences rather than documented rules. Shipping a heuristic is fine; shipping one dressed as a rule is not.

🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
Credentials none required; presence-only outputs supply them
Emitting a secret never β€” presence only β€”
False secrecy state caveat stated, not implied away β€”
A retired API reported, not refused set gcm_credential
A platform the provider cannot configure named as a gap β€”
A silent delivery failure derived flag on Sandbox choose it
A destructive block removal constant flag, and the bug named β€”
A hub with no platforms reported, not rejected β€”
A PEM file where contents belong rejected β€”
A heuristic check labelled as a heuristic β€”
A public key not redacted, following the provider β€”
Tags a policy expects βœ… supported here β€”
  • Before applying: an encrypted state backend.
  • Before applying: Production, not Sandbox.
  • Before applying: FCM v1 out of band, if Android matters.
  • Before removing a credential block: it replaces the hub.
  • Before destroying: device registrations do not come back.

πŸš€ Runbook

terraform init -backend=false
terraform validate
terraform fmt -check
  • Pin the source to a tag β€” ?ref=v1.0.0 β€” never a branch.
  • Plan-only from here. A human applies from CI.
  • πŸ”’ Confirm the state backend is encrypted and access-controlled (example 9).
  • πŸ”΄ Assert configures_the_retired_fcm_legacy_credential is false (example 5).
  • ⚠️ Assert apns_targets_the_sandbox_gateway is false in production (example 3).
  • ⚠️ Assert configures_no_push_platform is false for a live hub (example 1).
  • πŸ”΄ Record the out-of-band FCM v1 configuration somewhere durable (example 5).
  • ⚠️ Never remove a credential block casually (example 7).

πŸ§ͺ Testing

terraform validate and terraform fmt -check are the offline gate. They confirm:

  • name and location are non-empty, and name, namespace_name and resource_group_name are names rather than Resource IDs;
  • apns_credential.application_mode is exactly Production or Sandbox;
  • apns_credential.token does not contain the PEM markers, is not a .p8 filename, and is non-empty;
  • apns_credential.bundle_id contains a dot (a labelled heuristic), and key_id / team_id are non-empty;
  • browser_credential's three fields are non-empty, its subject is not a bare email address (a labelled heuristic), neither VAPID key is a PEM block, and the two halves differ;
  • gcm_credential.api_key is non-empty;
  • timeouts, when supplied, uses only the four keys the resource offers;
  • the module declares no provider block.

πŸ’‘ These were proved by evaluating the conditions in terraform console inside the module β€” which does fire root-module variable validations, unlike terraform validate on a calling configuration. Six .tfvars files were needed, grouped so one fault could not mask another: a token containing PEM markers would also fire the filename check, so those two were isolated.

βœ… Each check was proved with a value that must fail, and the legal call β€” a hub with both a Production APNS credential and a Web Push credential β€” was confirmed to produce no errors at all.

βœ… Every derived output was evaluated rather than assumed. A Sandbox hub with a legacy FCM key reports apns_targets_the_sandbox_gateway = true and has_gcm_credential = true; a credential-free hub reports configures_no_push_platform = true; the production call reports false for all three.

πŸ”΄ What the gate cannot check: whether the namespace is of type NotificationHub, whether location matches the namespace, whether the APNS token is a valid key, whether the bundle ID matches a real Apple application, or whether FCM v1 has been configured out of band.

What only plan and apply exercise:

  • whether the namespace exists and is of the right type;
  • whether Apple and Google accept the credentials β€” Azure validates them at configuration time.

What no Terraform command checks at any stage:

  • πŸ”΄ whether Android push works, since it is configured elsewhere (example 5);
  • ⚠️ whether the APNS gateway matches the app build (example 3);
  • πŸ”’ who can read the state file (example 9);
  • ⚠️ whether device registrations were lost by a replacement (examples 7, 11).

πŸ’¬ Example Output

Outputs:

apns_targets_the_sandbox_gateway                  = false
configures_no_push_platform                       = false
configures_the_retired_fcm_legacy_credential      = false
credentials_are_in_terraform_state_in_plaintext   = true
fcm_v1_cannot_be_configured_through_this_provider = true
has_apns_credential                               = true
has_browser_credential                            = true
has_gcm_credential                                = false
id                                                = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-push-prod/providers/Microsoft.NotificationHubs/namespaces/nhn-push-prod/notificationHubs/nh-exampleapp"
location                                          = "eastus2"
name                                              = "nh-exampleapp"
namespace_name                                    = "nhn-push-prod"
only_three_push_platforms_are_configurable_here   = true
removing_a_credential_block_replaces_the_hub      = true
resource_group_name                               = "rg-push-prod"

βœ… The three assertions worth automating all read false: not on the sandbox gateway, not without a platform, and not using the retired Android credential (examples 1, 3, 5).

πŸ”’ No credential appears anywhere in that output, by design β€” only has_* presence flags (example 9).

πŸ”΄ has_gcm_credential = false alongside fcm_v1_cannot_be_configured_through_this_provider = true is the honest picture: Android is not configured here, and it cannot be. That does not mean Android push is broken β€” it means it is managed somewhere Terraform cannot see (example 5).

πŸ”΄ The four true constants are facts, not statuses. credentials_are_in_terraform_state_in_plaintext does not mean something is misconfigured; it means check the backend.

πŸ” Troubleshooting

Symptom Cause Fix
iOS notifications silently undelivered application_mode = "Sandbox". Use Production (example 3).
Azure rejects the APNS credential The token includes PEM markers. Send the contents only (example 3).
Plan rejects apns_credential.token PEM markers, or a filename. See example 3.
Plan rejects bundle_id No dot β€” probably a team or key ID. Reverse-DNS (example 3).
Plan rejects a VAPID key A PEM block was pasted. base64url (example 4).
Plan rejects subject A bare email address. Use mailto: (example 4).
A VAPID rotation destroyed the hub browser_credential is force-new. Plan it as a migration (examples 4, 7).
Android notifications fail FCM legacy is retired. Configure FCM v1 out of band (example 5).
No FCM v1 arguments exist Provider gap. Portal, REST or SDK (example 5).
No WNS / ADM / Xiaomi arguments Provider gap. Out of band (example 6).
Removing a credential replaced the hub A documented Azure SDK bug. Expected (example 7).
Every device stopped receiving The hub was replaced. Registrations do not return (examples 7, 11).
Apply fails: namespace not found Wrong namespace or resource group name. Source all three from the namespace (example 8).
A secret appeared in a plan review It was in a .tfvars, not the resource. Use a secret store (examples 9, 10).
An import proposes setting credentials Azure does not return them. Supply from the original store (example 11).
Wanted prevent_destroy lifecycle is not valid inside a module block. Lock the namespace (example 11).

πŸ”— Related Docs

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