Skip to content

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

☁️ Azure Video Indexer Account Terraform Module

Manages an Azure AI Video Indexer account, whose most important prerequisite — a blob data role on its storage account — the provider neither creates nor checks (azurerm_video_indexer_account). Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources Caveat

🧩 Overview

  • 🎬 An ARM-based Azure AI Video Indexer account — the resource that indexes media and produces insights from it. Classic accounts retired on 30 June 2024 and nothing here can produce one.
  • 🔴 Its identity needs a blob data role on the storage account, and this module does not create it. Neither does the provider check it — and the apply succeeds without it. The account is created, and indexing fails later.
  • 🔴 Which is why Azure asks for Owner, or Contributor plus User Access Administrator: a caller with only Contributor cannot write that role assignment, so they will create an account that cannot index anything.
  • 🔴 Both identity and storage are required blocks, so there is no empty call to harden — the module defaults the identity to SystemAssigned, which needs nothing from the caller and involves no credential.
  • 🔒 public_network_access defaults to Disabled here, diverging from the provider's Enabled, and is taken as a positively-stated boolean so there is no double negative to misread.
  • ⚠️ The storage account must be Standard general-purpose v2 — Azure documents it, the provider does not, and it is not visible from a Resource ID.
  • ⚠️ storage.user_assigned_identity_id should be one of identity.identity_ids, which spans two variables and so cannot be validated. The module emits storage_access_identity_is_assigned_to_account instead of pretending to check.

💡 Why it matters: Everything hard about this resource is outside it. The schema has eight fields and none of them records the role assignment that makes the account work, the storage account kind that Azure requires, or the firewall exception a locked-down storage account needs. A plan that looks perfect can produce an account that indexes nothing.

❤️ Support this project

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


🗺️ Where this fits in the family

flowchart TB
  rg["terraform-azurerm-resource-group"]
  sa["terraform-azurerm-storage-account. Must be Standard general-purpose v2. And if its own public access is closed, it must allow TRUSTED AZURE SERVICES through the firewall - two correct secure defaults that break each other."]
  uai["terraform-azurerm-user-assigned-identity, optional. The default is a system-assigned identity, which needs nothing from the caller."]
  vi["terraform-azurerm-video-indexer-account"]
  ra["terraform-azurerm-role-assignments: the BLOB DATA ROLE on the storage account. NOT created here, NOT checked by the provider - and its absence does not fail the apply. The account is created and INDEXING FAILS LATER."]
  pe["terraform-azurerm-private-endpoint. Needed because this module closes public network access by default."]
  diag["terraform-azurerm-monitor-diagnostic-setting"]

  rg -->|"resource_group_name and location"| vi
  sa -->|"storage.storage_account_id, force-new"| vi
  uai -->|"identity.identity_ids, when not system-assigned"| vi
  vi -->|"principal_id and storage_account_id feed the assignment"| ra
  ra -->|"grants blob data access, or indexing silently fails"| sa
  vi -->|"id"| pe
  vi -->|"id"| diag

  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 vi mine;
  class ra keystone;
  class rg,sa,uai,pe,diag sib;
Loading

🧬 What this module builds

flowchart TB
  name["name: 3-50 characters of letters, digits and hyphens. An ARM constraint the provider does not publish, so it is enforced here rather than left to fail at apply. Force-new."]
  ident["identity is a REQUIRED block, so there is no absent-identity case to harden. Defaults to SystemAssigned - no credential, no caller-managed lifecycle. Both halves of the identity_ids pairing are checked on this one variable, because a validation may only reference its own variable."]
  space["And the combined value is SystemAssigned, UserAssigned - WITH A SPACE. The ARM API spells it without one, so the error message names that near-miss explicitly."]
  stor["storage is also REQUIRED. storage_account_id is force-new, must be a Standard general-purpose v2 account, and neither its kind nor its tier is visible from a Resource ID."]
  pna["public_network_access defaults to Enabled in the provider and to DISABLED here. Taken as a positively-stated boolean so there is no double negative to misread."]
  this["azurerm_video_indexer_account.this"]
  cross["storage_access_identity_is_assigned_to_account: the check Terraform cannot make, because it spans two variables. Emitted as a derived flag instead of pretended. Assert true."]
  role["requires_blob_data_role_assignment_on_storage: always true, and the module's most important output. The identity needs a blob data role on the storage account. Nothing here creates or checks it, the apply SUCCEEDS without it, and indexing fails later."]
  outputs["id, principal_id, storage_account_id, identity_type, has_system_assigned_identity, storage_access_identity_id, public_network_access_enabled, storage_must_be_standard_general_purpose_v2"]

  name -->|"validated"| this
  ident -->|"required block"| this
  ident -->|"near-miss"| space
  space -->|"rejected by name"| this
  stor -->|"required block, force-new"| this
  pna -->|"secure divergence"| this
  this -->|"exports"| outputs
  this -->|"cannot validate, so emits"| cross
  this -->|"prerequisite you must act on"| role
  role -->|"grant it to"| outputs

  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 pna mine;
  class role,cross keystone;
  class name,ident,space,stor,outputs sib;
Loading

Resource inventory

Resource Count Notes
azurerm_video_indexer_account.this 1 The keystone.
identity block 1 Required by the provider — min_items = 1.
storage block 1 Required by the provider — min_items = 1.
timeouts block 0..1 Provider defaults are ordinary (30 min).

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Azure resource provider Microsoft.VideoIndexer/accounts — current API version 2025-01-01
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 its documentation:

  • 🔴 The blob data role assignment is required, uncreated and unchecked — and its absence does not fail the apply.
  • 🔴 identity and storage are both required blocks.
  • 🔴 Force-new: name, resource_group_name, location, storage.storage_account_id.
  • ⚠️ identity.type's combined value is "SystemAssigned, UserAssigned" — with a space. The ARM API spells it without one.
  • ⚠️ None is a valid ARM identity type but not a provider one. This resource always carries an identity.
  • ⚠️ public_network_access defaults to Enabled in the provider; this module defaults it closed.
  • ⚠️ The storage account must be Standard general-purpose v2, documented by Azure and not by the provider.
  • ⚠️ ARM constrains name to 3–50 characters of [A-Za-z0-9-], which the provider does not document.
  • ⚠️ principal_id is empty when identity.type is UserAssigned alone — see example 5.
  • ℹ️ The Azure OpenAI connection the ARM API offers is not exposed by the provider, so it cannot be managed here.
  • lifecycle is not valid inside a module block, so a caller cannot add prevent_destroy (example 12).

🔑 Required Azure RBAC Roles / Permissions

Operation Role Scope
Create, update or delete the account Contributor the resource group
Read the account Reader the account
🔴 Assign the blob data role to the account's identity User Access Administrator or Owner the storage account
Read the storage account being associated Reader the storage account
Assign a user-assigned identity to the account Microsoft.ManagedIdentity/userAssignedIdentities/assign/action the identity
Use the Video Indexer website / API read-only Video Indexer Restricted Viewer the account

🔴 This is why Azure's guidance asks for subscription-level Owner, or Contributor plus User Access Administrator — not because the account is privileged, but because creating a working one means writing a role assignment, and Contributor cannot (example 3).

✅ Video Indexer Restricted Viewer is the role most people actually want: view videos and their insights, with no ability to change the environment or manage users (example 10).

Azure Prerequisites

  • 🔴 A blob data role assignment for the account's identity on the storage account — Storage Blob Data Owner (what the portal assigns) or Storage Blob Data Contributor (what Microsoft's deployment samples assign). Its absence does not fail the apply (example 3).
  • 🔴 A Standard general-purpose v2 (StorageV2) storage account (example 4).
  • A region where Video Indexer is available.
  • ⚠️ A trusted-services firewall exception on the storage account, if that account's own public access is closed — which this suite's storage module defaults to (example 8).
  • A private endpoint, if public_network_access_enabled is left at its default false (example 7).

📁 Module Structure

terraform-azurerm-video-indexer-account/
├── providers.tf   # required_version + the pinned azurerm provider. No provider block.
├── variables.tf   # name, resource_group_name, location, identity, storage,
#                  # public_network_access_enabled, tags, timeouts
├── main.tf        # the keystone `this` + its two required nested blocks
├── outputs.tf     # id first, then identity and storage facts, then the prerequisite flags
├── README.md      # this document
├── SCOPE.md       # the cross-module contract
├── LICENSE        # MIT
└── .gitignore

⚙️ Quick Start

provider "azurerm" {
  features {}
}

module "video_indexer" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-video-indexer-account.git?ref=v1.0.0"

  name                = "vi-media-insights"
  resource_group_name = module.rg.name
  location            = module.rg.location

  # ⚠️ Required by the provider. Must be Standard general-purpose v2 (example 4).
  storage = {
    storage_account_id = module.vi_storage.id
  }

  # identity defaults to SystemAssigned — nothing needed here (example 5).
  # public_network_access_enabled defaults to false (example 7).

  tags = { owner = "media-platform" }
}

🔴 This is not finished. The account's identity still needs a blob data role on that storage account, and nothing above creates it. See example 3 — without it the apply succeeds and indexing fails later.

ℹ️ 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; 3–50 chars of [A-Za-z0-9-]
resource_group_name / location string terraform-azurerm-resource-group
identity object(...) terraform-azurerm-user-assigned-identity, when not system-assigned
storage object(...) terraform-azurerm-storage-account — required
public_network_access_enabled bool caller — defaults to false
tags map(string) caller
timeouts object(...) caller

Emits

Output Description Consumed by
id The account's Resource ID. private endpoint, diagnostics, RBAC
name / resource_group_name / location Identity. Force-new. review
identity_type Which identity model is in use. review
principal_id 🔑 What you grant the blob data role to. terraform-azurerm-role-assignments
tenant_id The identity's tenant. review
has_system_assigned_identity ✅ Derived, positive. review
identity_ids Attached user-assigned identities. review
storage_account_id 🔴 Force-new. The role assignment's scope. terraform-azurerm-role-assignments
storage_access_identity_id Which identity reaches storage, or null. security review
storage_access_identity_is_assigned_to_account ✅ Derived. Assert true. security review
public_network_access_enabled ✅ Positively stated. network review
requires_blob_data_role_assignment_on_storage 🔴 Always true. prerequisite review
storage_must_be_standard_general_purpose_v2 Always true. prerequisite review

📚 Example Library

The examples below reference existing resources by ID or name rather than creating them; this module owns only its own resource. Those references are declared inputs:

variable "other_storage_id" {
  description = "id of an existing other storage that these examples reference but do not create."
  type        = string
}

variable "vi_identity_id" {
  description = "id of an existing vi identity that these examples reference but do not create."
  type        = string
}

variable "vi_identity_principal_id" {
  description = "principal id of an existing vi identity that these examples reference but do not create."
  type        = string
}
1 · The smallest working account, and what "working" leaves out
module "video_indexer" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-video-indexer-account.git?ref=v1.0.0"

  name                = "vi-media-insights"
  resource_group_name = module.rg.name
  location            = module.rg.location

  storage = {
    storage_account_id = module.vi_storage.id
  }
}

✅ Two of the three required things are defaulted. identity defaults to SystemAssigned and public_network_access_enabled defaults to false, so storage is the only block a caller must supply.

🔴 And this configuration applies cleanly while producing an account that cannot index. The blob data role assignment is missing (example 3), and no Terraform command anywhere will tell you.

⚠️ storage has no default because the provider makes it required — the account cannot exist without a storage account, so there is no empty call for this suite's secure-default rule to harden.

ℹ️ Everything the account actually does happens in the data plane — uploads, indexing jobs, insights, custom models — through the Video Indexer API rather than Azure Resource Manager. This resource is the container for it.

2 · What is force-new and what is not
# 🔴 Force-new:
name                = "vi-media-insights-v2"
resource_group_name = "rg-other"
location            = "westus2"
storage             = { storage_account_id = var.other_storage_id }

# ✅ Updates in place:
identity                      = { type = "SystemAssigned, UserAssigned", identity_ids = [var.vi_identity_id] }
public_network_access_enabled = true
storage                       = { storage_account_id = module.vi_storage.id, user_assigned_identity_id = var.vi_identity_id }
tags                          = { owner = "media-platform" }

ℹ️ The split is unusually clean. Placement and the storage pairing are fixed; the identity model, the storage access identity, network exposure and tags are all mutable.

⚠️ Note the two storage lines. Changing storage_account_id replaces the account; changing user_assigned_identity_id inside the same block does not. Two fields, one block, opposite lifecycles.

✅ So tightening security later is cheap. Closing public access, moving from a system-assigned identity to a user-assigned one, or switching which identity reaches storage are all in-place updates.

🔴 What is not cheap is repointing storage. That is a new account and, in practice, a re-index.

3 · 🔴 The role assignment this module does not create
# This module creates the account:
module "video_indexer" { /* … */ }

# 🔴 And this is the part everybody forgets. Without it, the account cannot read or write its
#    own storage — and NOTHING in the apply above says so.
module "vi_storage_role" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"

  scope = module.video_indexer.storage_account_id # the storage account
  role_assignments = {
    vi_blob = {
      role_definition_name = "Storage Blob Data Contributor"
      principal_id         = module.video_indexer.principal_id # the account's identity
    }
  }
}

🔴 The failure mode is the worst kind: silent success. terraform apply completes, the account exists, the portal shows it as healthy — and the first upload fails. Nothing connects that symptom back to this omission.

✅ The portal assigns Storage Blob Data Owner; Microsoft's own deployment samples assign Storage Blob Data Contributor. Either works. Prefer Contributor unless you need the Owner-level operations.

⚠️ Contributor on the resource group is not enough to create this assignment. Writing a role assignment needs User Access Administrator or Owner — which is exactly why Azure's own guidance asks for subscription-level Owner, or Contributor plus User Access Administrator.

⚠️ Grant it to the right principal. principal_id is the system-assigned identity, and it is empty when identity.type is UserAssigned alone (example 5).

✅ The module emits a constant flag rather than trusting a comment, because the fact is consequential, invisible in state, and inferable from nothing else:

output "reminder" {
  value = module.video_indexer.requires_blob_data_role_assignment_on_storage # always true
}
4 · ⚠️ The storage account must be general-purpose v2
storage = {
  storage_account_id = module.vi_storage.id # must be Standard StorageV2
}

⚠️ Azure requires a Standard general-purpose v2 (StorageV2) account. Premium accounts and the legacy kinds are rejected — at apply, with an error about the storage association rather than about the account's kind.

⚠️ The provider does not document this, and neither the kind nor the tier is visible from a Resource ID. So the module validates the ID's shape and states the requirement it cannot check:

output "check_your_storage" {
  value = module.video_indexer.storage_must_be_standard_general_purpose_v2 # always true
}

✅ The ID check is anchored with $, so a container or a blob-service child ID under the right storage account is rejected too:

Error: Invalid value for variable

  storage.storage_account_id must be a full storage account Resource ID ending in
  /providers/Microsoft.Storage/storageAccounts/<name> — anchored, so a container or
  blob-service child ID is rejected too. ⚠️ It must also be a Standard general-purpose v2
  account, which this module cannot verify from an ID.

🔴 Force-new, so a wrong storage account is not a correctable mistake (example 2).

💡 Give Video Indexer its own storage account. It writes its own artifacts there, and the pairing is immutable — sharing an account with something else makes both harder to reason about.

5 · The identity, and the empty `principal_id` trap
identity = { type = "SystemAssigned" }                                            # ✅ the default
identity = { type = "UserAssigned", identity_ids = [var.vi_identity_id] }      # ⚠️ principal_id is EMPTY
identity = { type = "SystemAssigned, UserAssigned", identity_ids = [var.vi_identity_id] } # both

✅ SystemAssigned is the default because it is the credential-free option — Azure creates and rotates the identity, and there is nothing for a caller to manage. The provider makes the block required, so the module cannot default to no identity; it defaults to the safest one.

⚠️ principal_id reports the system-assigned identity only, and is empty for UserAssigned alone. So a composition that grants the blob data role to principal_id unconditionally will silently grant nothing on a user-assigned account. Grant it to the user-assigned identity's own principal instead.

✅ has_system_assigned_identity is emitted, positively stated, so that condition is easy to branch on:

principal_id = module.video_indexer.has_system_assigned_identity ? module.video_indexer.principal_id : var.vi_identity_principal_id

⚠️ Not force-new. You can move from a system-assigned identity to a user-assigned one later — but remember to move the role assignment with it.

6 · ⚠️ The space in `"SystemAssigned, UserAssigned"`
identity = { type = "SystemAssigned, UserAssigned", identity_ids = [var.vi_identity_id] } # ✅
identity = { type = "SystemAssigned,UserAssigned",  identity_ids = [var.vi_identity_id] } # ⚠️ rejected

⚠️ The provider wants a space after the comma; the ARM API spells it without one. Anybody working from the ARM template reference will type the second form, so the module names that near-miss explicitly rather than listing legal values and leaving the reader to spot the difference:

Error: Invalid value for variable

  identity.type must be exactly one of: "SystemAssigned", "UserAssigned",
  "SystemAssigned, UserAssigned". ⚠️ The combined value needs a SPACE after the comma —
  "SystemAssigned,UserAssigned" is the ARM API's spelling and is rejected here. Also note
  "None" is an ARM value but not a provider one: this resource always carries an identity.

ℹ️ None is the other ARM-only value. It exists in the ARM identity type enum and is not accepted here, because the provider requires an identity block on this resource.

✅ Both halves of the identity_ids pairing are checked on the identity variable, since a validation {} condition may only reference its own variable:

identity.identity_ids must contain at least one … when identity.type is "UserAssigned" …
identity.identity_ids must be empty when identity.type is "SystemAssigned", which is the
likelier mistake than the reverse: supplying IDs alongside "SystemAssigned" silently means
nothing. ✅ Use "SystemAssigned, UserAssigned" (with the space) if you want both.

💡 The second check earns its place. IDs supplied alongside SystemAssigned are not an error to Azure — they are simply ignored, which is why catching it at plan time is worth doing.

7 · 🔒 Closed by default, and how you then reach it
# ✅ The default — the provider's own default is "Enabled":
# public_network_access_enabled = false

# ⚠️ Public reachability, typed deliberately:
public_network_access_enabled = true

🔒 This diverges from the provider on purpose. Per this suite's secure-by-default rule the empty call produces the closed resource, and the caller types the relaxation.

✅ Stated as a positive boolean, not as the provider's Enabled/Disabled string and not as a ..._disabled flag. Double negatives are where security reviews go wrong, so the module renders the enum internally and the output reads the same way as the input:

output "exposed" {
  value = module.video_indexer.public_network_access_enabled # false is the good answer
}

⚠️ false means you need a private endpoint to use the account. Video Indexer supports them, so the closed default has a real path — but it is a path you must build.

✅ Not force-new, so an account created open can be closed later without replacement (example 2).

8 · ⚠️ Two secure defaults that break each other
This module:                public_network_access_enabled = false   ✅
The storage-account module:  public_network_access_enabled = false   ✅
Together:                    Video Indexer cannot reach its storage  ⚠️

⚠️ A storage account with public access disabled blocks Video Indexer too, unless that storage account also allows trusted Azure services through its firewall. Both defaults are individually correct; the pairing is what breaks.

✅ Fix it on the storage account, not here. Enable the trusted-services exception on the storage account's network rules — Azure documents this exact combination as the supported way to run Video Indexer behind a firewall.

⚠️ This module cannot see it or fix it. The setting belongs to a resource this module only references by ID, so it is documented as a prerequisite rather than worked around.

💡 Worth generalising: whenever two modules in this library each close a door by default, check the path between them. Neither default is wrong, and the composition still needs a decision.

9 · The cross-variable check Terraform cannot make
identity = {
  type         = "UserAssigned"
  identity_ids = [var.vi_identity_id]
}

storage = {
  storage_account_id        = module.vi_storage.id
  user_assigned_identity_id = var.vi_identity_id # ✅ must be one of identity_ids above
}

⚠️ A validation {} condition may only reference its own variable, so the module cannot check storage against identity. Per this suite's rule, a check that cannot be placed becomes a derived flag rather than a pretend validation:

output "wired_correctly" {
  value = module.video_indexer.storage_access_identity_is_assigned_to_account # assert true
}

✅ The flag covers both cases. With user_assigned_identity_id omitted, access falls to the account's system-assigned identity — so the flag is true only when identity.type includes SystemAssigned. With it supplied, the flag is true only when that ID is one of identity_ids. The comparison is case-insensitive, because Azure Resource IDs are.

🔴 false means the account will be created and then fail to reach its storage — the same silent failure as example 3, from a different cause.

💡 storage_access_identity_id is emitted alongside it, so a reviewer can see which principal needs the blob data role. principal_id alone does not answer that question on a user-assigned account.

10 · What a review should assert
output "vi_review" {
  value = {
    id       = module.video_indexer.id
    exposed  = module.video_indexer.public_network_access_enabled # ✅ expect false
    identity = module.video_indexer.identity_type
    sys_mi   = module.video_indexer.has_system_assigned_identity
    storage  = module.video_indexer.storage_account_id
    accessor = module.video_indexer.storage_access_identity_id
    wired    = module.video_indexer.storage_access_identity_is_assigned_to_account # ✅ expect true
    # 🔴 Prompts, not values:
    needs_role = module.video_indexer.requires_blob_data_role_assignment_on_storage
    needs_gpv2 = module.video_indexer.storage_must_be_standard_general_purpose_v2
  }
}

✅ exposed = false and wired = true are the two assertions worth automating. Both describe a state that is easy to get wrong and cheap to check.

🔴 needs_role is an instruction. The review cannot verify the assignment exists — only remind you that it must (example 3).

✅ Grant humans Video Indexer Restricted Viewer rather than Reader on the account. It permits viewing videos and their insights while forbidding changes to the environment and user management, which is what most consumers of the account actually need.

⚠️ What no output tells you is whether the indexed media is lawful to hold, how long it is retained, or who can see the insights derived from it. Video Indexer processes faces and speech, so those questions belong with whoever owns data protection — not in a module.

11 · The name constraint the provider does not document
name = "vi-media-insights"  # ✅ letters, digits, hyphens; 3–50 characters
name = "vi_media_insights"  # ⚠️ rejected — underscores are not allowed
name = "vi"                 # ⚠️ rejected — under 3 characters

⚠️ Azure Resource Manager constrains this name to 3–50 characters of [A-Za-z0-9-]. The provider does not publish the rule, so without this check an invalid name reaches Azure and fails at apply.

✅ The rule is enforced anyway, because Azure documents it. This library's position is to enforce what the platform documents and to document what it does not — the ARM template reference publishes this pattern, so it is enforceable rather than invented:

Error: Invalid value for variable

  name must be 3-50 characters of letters, digits and hyphens only — Azure Resource Manager
  rejects underscores, periods and other punctuation for this resource type. The provider
  does not publish this constraint, so an invalid name fails at apply rather than at plan.

ℹ️ Underscores are the likely mistake, since plenty of Azure resource types accept them.

🔴 Force-new, so a name that only looks wrong is still a replacement to fix (example 2).

12 · Destroy, locks and importing
terraform destroy on this module:
  removes the ACCOUNT      ->  and the account's indexed insights
  the storage account survives, with its blobs
  the role assignment survives, now pointing at a deleted principal
  a user-assigned identity survives

⚠️ Deleting the account leaves an orphaned role assignment whose principal no longer exists. Harmless, but it will show up in a governance review as an unresolvable assignment — clean it up with the account.

✅ A CanNotDelete management lock is worth applying to an account holding indexed insights. It prevents deletion but not the force-new replacement an edited storage_account_id would cause.

⚠️ prevent_destroy is not available, because lifecycle is not valid inside a module block.

Importing an existing account

terraform import 'module.video_indexer.azurerm_video_indexer_account.this' \
  "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-media/providers/Microsoft.VideoIndexer/accounts/vi-media-insights"

⚠️ Restate storage.storage_account_id exactly. It is force-new, so a mismatch is a replacement rather than an update — and a replacement means re-indexing everything.

⚠️ Also check public_network_access_enabled before the first plan. This module defaults it to false, so importing an open account without setting it to true produces a plan that closes it — which may be what you want, but should not be a surprise.

💡 An imported account probably already has its role assignment. Import it too, or at least record it, so a later destroy/create cycle does not lose the prerequisite silently.

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-media-platform"
  location = "eastus"
}

# ── Storage: Standard general-purpose v2 (example 4) ─────────────────────────
# ⚠️ If this account closes its own public access — which that module defaults to —
#    it must also allow TRUSTED AZURE SERVICES through its firewall, or Video
#    Indexer cannot reach it (example 8).
module "vi_storage" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"

  name                = "stvimediainsights"
  resource_group_name = module.rg.name
  location            = module.rg.location
}

# ── The account itself ──────────────────────────────────────────────────────
module "video_indexer" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-video-indexer-account.git?ref=v1.0.0"

  # ⚠️ 3–50 chars of letters, digits and hyphens (example 11). Force-new.
  name                = "vi-media-insights"
  resource_group_name = module.rg.name
  location            = module.rg.location

  # ✅ Credential-free, and the module's default — stated here for the reader (example 5).
  identity = {
    type = "SystemAssigned"
  }

  # 🔴 Force-new, and must be Standard general-purpose v2 (example 4).
  storage = {
    storage_account_id = module.vi_storage.id
    # user_assigned_identity_id omitted -> the system-assigned identity reaches
    # storage, which is why identity.type must include SystemAssigned (example 9).
  }

  # 🔒 The default, restated so the posture is visible in the composition (example 7).
  # Reaching a closed account needs a private endpoint.
  public_network_access_enabled = false

  tags = {
    owner      = "media-platform"
    data_class = "media-with-faces-and-speech"
  }
}

# ── 🔴 The prerequisite the account cannot create for itself (example 3) ─────
# Without this the applies above all succeed and INDEXING FAILS LATER.
module "vi_storage_role" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"

  scope = module.video_indexer.storage_account_id
  role_assignments = {
    vi_blob = {
      role_definition_name = "Storage Blob Data Contributor"
      principal_id         = module.video_indexer.principal_id
    }
  }
}

# ── A private endpoint, because the account is closed (example 7) ────────────
#   module "vi_endpoint" {
#     source = ".../terraform-azurerm-private-endpoint..."
#     ...    = module.video_indexer.id
#   }

output "vi_posture" {
  value = {
    id      = module.video_indexer.id
    exposed = module.video_indexer.public_network_access_enabled                  # ✅ false
    wired   = module.video_indexer.storage_access_identity_is_assigned_to_account # ✅ true
    storage = module.video_indexer.storage_account_id
  }
}

🔒 What the composition gets right: a dedicated general-purpose v2 storage account, a system-assigned identity so no credential exists to leak, public access closed with the private endpoint acknowledged, the blob data role assignment present and scoped to the storage account rather than the resource group, principal_id used as the grantee because the identity is system-assigned, a data classification tag that names what the account actually processes, and the two assertions worth automating emitted together.

⚠️ Note the ordering is implicit: vi_storage_role references module.video_indexer.principal_id, so Terraform creates the account before the assignment without any depends_on.

🔴 What still needs a human: the trusted-services exception on the storage account (example 8), the private endpoint, and the retention and lawful-basis questions that come with indexing faces and speech (example 10).

📥 Inputs

Input Type Default Notes
name string — Required. Force-new. 3–50 chars of [A-Za-z0-9-].
resource_group_name / location string — Required. Force-new.
identity object(...) {} → SystemAssigned Required block; defaults to the credential-free option.
storage object(...) — Required. storage_account_id is force-new.
public_network_access_enabled bool false 🔒 Diverges from the provider's Enabled. Not force-new.
tags map(string) {} —
timeouts object(...) null Provider defaults are ordinary (30 min).
Full schemas
variable "identity" {
  type = object({
    type         = optional(string, "SystemAssigned")
    identity_ids = optional(list(string), [])
  })
  default = {}
  # Required by the provider, so it always renders — defaulted to the credential-free option. Both halves of the
  # identity_ids pairing are checked here, because a validation may only reference its own variable. See examples 5–6.
  # ✅ type: "SystemAssigned" | "UserAssigned" | "SystemAssigned, UserAssigned"  (note the SPACE)
  # ⚠️ identity_ids: required for UserAssigned, rejected for SystemAssigned, each entry an anchored
  #    /providers/Microsoft.ManagedIdentity/userAssignedIdentities/<name> ID.
}

variable "storage" {
  type = object({
    storage_account_id        = string
    user_assigned_identity_id = optional(string)
  })
  # Required by the provider. storage_account_id is FORCE-NEW and must be a Standard general-purpose v2 account —
  # documented by Azure, not by the provider, and invisible from an ID. See example 4.
  # ℹ️ user_assigned_identity_id omitted -> access falls to the system-assigned identity; supplied -> it should be one
  #    of identity.identity_ids, which spans two variables and so is emitted as a flag. See example 9.
}

variable "public_network_access_enabled" {
  type    = bool
  default = false
  # 🔒 Diverges from the provider's "Enabled" default. Taken as a positive boolean and rendered to the provider's
  # string enum, so there is no double negative to misread. Not force-new. See example 7.
}

🧾 Outputs

Output Description Sensitive
id The account's Resource ID. no
name / resource_group_name / location Identity. Force-new. no
identity_type Which identity model is in use. no
principal_id 🔑 The system-assigned identity's object ID. Empty for UserAssigned alone. no
tenant_id The identity's tenant. Empty for UserAssigned alone. no
has_system_assigned_identity ✅ Derived, positive. no
identity_ids Attached user-assigned identities. no
storage_account_id 🔴 Force-new. The role assignment's scope. no
storage_access_identity_id Which identity reaches storage, or null. no
storage_access_identity_is_assigned_to_account ✅ Derived. Assert true. no
public_network_access_enabled ✅ Positively stated. no
requires_blob_data_role_assignment_on_storage 🔴 Always true. no
storage_must_be_standard_general_purpose_v2 Always true. no

ℹ️ Nothing here is secret. principal_id is an object ID, which is an identifier rather than a credential — the account holds no key or connection string this module could emit.

🧠 Architecture Notes

  • The module's centre of gravity is a prerequisite outside its own resource. Video Indexer reaches storage through a managed identity, that identity needs a blob data role on the storage account, and the provider neither creates the assignment nor checks it. Crucially, the apply succeeds without it — so the failure appears at the first upload, disconnected from the change that caused it. That is why the fact is a constant output rather than a comment: it is consequential, invisible in state, and inferable from nothing else in the plan.

  • The RBAC consequence is stated where it will be read. Contributor cannot write role assignments, so a caller granted only Contributor produces an account that cannot index — which is the real reason Azure's guidance asks for Owner, or Contributor plus User Access Administrator. Naming the reason beats copying the requirement.

  • Both nested blocks are required, so this suite's secure-by-default rule has nothing to harden in the empty call. The module says so rather than inventing safety: storage has no default because the account cannot exist without storage, and identity defaults to SystemAssigned because when the choice is which identity rather than whether, the credential-free one is the safe answer.

  • public_network_access is the one field where a secure default is available, and the divergence is deliberate. The provider defaults it to Enabled; the module defaults it closed and takes it as a positively-stated boolean, rendering the provider's string enum internally. Double negatives are where security reviews go wrong.

  • The storage ↔ identity pairing is emitted rather than enforced, because Terraform cannot enforce it. A validation {} condition may only reference its own variable, so cross-variable checks must be placed rather than gathered — and where they cannot be placed at all, this suite's rule is a derived flag that leaves the judgement with the reader. storage_access_identity_is_assigned_to_account handles both the supplied and the omitted case, comparing case-insensitively because Azure Resource IDs are.

  • The identity_ids pairing can be placed, so it is — in both directions. The reverse direction matters more than it looks: IDs supplied alongside SystemAssigned are silently ignored by Azure, which is exactly the kind of no-signal mistake worth catching at plan time.

  • The "SystemAssigned, UserAssigned" space is named in the error message. The ARM API omits it, so the near-miss is what somebody reading ARM documentation will type. Per this suite's validation philosophy, naming the probable mistake beats listing legal values and leaving the reader to spot a space.

  • The ARM name constraint is enforced although the provider does not publish it. The rule here is to enforce what the platform documents — and the ARM template reference publishes the 3–50 character [A-Za-z0-9-] pattern, so this is enforcing a documented constraint rather than inventing one.

  • principal_id being empty for UserAssigned alone is called out repeatedly, because a composition that grants a role to it unconditionally grants nothing — a silent failure with the same symptom as the missing assignment itself. has_system_assigned_identity exists to make that branch easy to write.

  • The trusted-services interaction is documented rather than worked around. Two independently correct secure defaults — this module closing the account, the storage module closing the storage account — break the path between them, and the fix belongs on a resource this module only references by ID.

  • Privacy, not secrecy, is the residual risk. Video Indexer processes faces and speech, so retention and lawful basis are the questions that matter most about this account and none of them is answerable in a module. They are flagged for whoever owns data protection.

🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
Public network access false — diverges from the provider's Enabled set to true
Negated security fields stated positively as ..._enabled —
Credentials SystemAssigned identity by default — nothing to leak supply user-assigned identities
A required block with no safe absence said plainly, not faked —
An unenforceable cross-variable rule emitted as a derived flag —
Silently ignored input identity_ids rejected for SystemAssigned —
A prerequisite that fails invisibly constant flag for the blob data role —
An unverifiable storage kind constant flag for general-purpose v2 —
Wrong-resource IDs anchored regexes with $ —
An undocumented platform constraint ARM name pattern enforced —
Colliding secure defaults trusted-services exception documented —
  • Before the first upload: create the blob data role assignment. Nothing else will tell you.
  • Before granting plan rights: Contributor alone cannot finish this job.
  • Before choosing storage: Standard general-purpose v2, dedicated, and force-new.
  • Before locking down storage: allow trusted Azure services, or Video Indexer cannot reach it.
  • Before indexing real media: settle retention and lawful basis with whoever owns data protection.

🚀 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.
  • 🔴 Create the blob data role assignment, or the account will not index (example 3).
  • 🔴 Force-new: name, resource_group_name, location, storage.storage_account_id.
  • ✅ In place: the identity block, public_network_access_enabled, the storage access identity, tags.
  • ⚠️ Assert storage_access_identity_is_assigned_to_account is true (example 9).
  • ⚠️ Assert public_network_access_enabled is false unless exposure is deliberate.
  • ⚠️ Importing? Restate storage_account_id and check the network default first (example 12).
  • 💡 A CanNotDelete lock guards deletion, not replacement.

🧪 Testing

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

  • name is 3–50 characters of letters, digits and hyphens — an ARM constraint the provider does not publish;
  • resource_group_name and location are non-empty;
  • identity.type is exactly one of the three provider values, space included;
  • identity.identity_ids is non-empty for UserAssigned and empty for SystemAssigned;
  • every entry in identity.identity_ids is an anchored user-assigned identity Resource ID;
  • storage.storage_account_id is an anchored storage account ID — a container or blob-service child ID is rejected;
  • storage.user_assigned_identity_id, when supplied, is an anchored user-assigned identity ID;
  • 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. "SystemAssigned,UserAssigned" fires the space check, "UserAssigned" with no IDs fires the required-IDs check, "SystemAssigned" with IDs fires the reverse check, a container ID fires the storage anchor, and "vi_media" fires the name pattern.

⚠️ And note a property of the harness: Terraform skips a validation whose referenced variable has already failed, so a short error list is not proof that a check is missing. Fix the first fault and re-run before concluding anything.

What only plan and apply exercise:

  • whether the storage account exists and is a Standard general-purpose v2 account;
  • whether the region offers Video Indexer;
  • whether the caller may create the account and associate that storage.

What no Terraform command checks at any stage:

  • 🔴 whether the blob data role assignment exists — and the apply succeeds without it;
  • ⚠️ whether the storage account allows trusted Azure services through its firewall;
  • ⚠️ whether a private endpoint exists for a closed account;
  • whether the media being indexed may lawfully be held, and for how long.

💬 Example Output

Outputs:

has_system_assigned_identity                   = true
id                                             = "/subscriptions/00000000-.../providers/Microsoft.VideoIndexer/accounts/vi-media-insights"
identity_ids                                   = []
identity_type                                  = "SystemAssigned"
location                                       = "eastus"
name                                           = "vi-media-insights"
principal_id                                   = "11111111-2222-3333-4444-555555555555"
public_network_access_enabled                  = false
requires_blob_data_role_assignment_on_storage   = true
resource_group_name                            = "rg-media-platform"
storage_access_identity_id                     = null
storage_access_identity_is_assigned_to_account  = true
storage_account_id                             = "/subscriptions/00000000-.../providers/Microsoft.Storage/storageAccounts/stvimediainsights"
storage_must_be_standard_general_purpose_v2     = true
tenant_id                                      = "99999999-8888-7777-6666-555555555555"

✅ public_network_access_enabled = false — the module's default, and the divergence from the provider (example 7).

✅ storage_access_identity_is_assigned_to_account = true with storage_access_identity_id = null: access falls to the system-assigned identity, and identity_type includes SystemAssigned, so the pairing holds (example 9).

🔑 principal_id is the grantee for the blob data role on storage_account_id (example 3). On a UserAssigned-only account it would be empty (example 5).

🔴 requires_blob_data_role_assignment_on_storage = true is a reminder, not a status. It does not mean the assignment exists.

🔍 Troubleshooting

Symptom Cause Fix
Apply succeeded but indexing fails The blob data role assignment is missing. Create it on the storage account (example 3).
The role assignment cannot be created The caller has Contributor only. User Access Administrator or Owner (example 3).
A role granted to principal_id did nothing It is empty for UserAssigned alone. Grant to the user-assigned principal (example 5).
Apply fails associating storage Not a Standard general-purpose v2 account. Use a StorageV2 account (example 4).
The account cannot reach locked-down storage No trusted-services exception. Enable it on the storage account (example 8).
Plan rejects identity.type "SystemAssigned,UserAssigned" — no space. Add the space (example 6).
Plan rejects identity.type = "None" ARM value, not a provider value. This resource always has an identity (example 6).
Plan rejects empty identity_ids type is UserAssigned. Supply IDs, or use SystemAssigned (example 6).
Plan rejects non-empty identity_ids type is SystemAssigned. Use "SystemAssigned, UserAssigned" (example 6).
Plan rejects storage_account_id A container or child ID was passed. Pass the storage account's id (example 4).
Plan rejects name Underscore, or under 3 characters. Letters, digits, hyphens; 3–50 (example 11).
storage_access_identity_is_assigned_to_account is false The storage identity is not attached. Add it to identity_ids (example 9).
The account is unreachable after apply It is closed by default. Add a private endpoint, or set true (example 7).
Fewer validation errors than faults Terraform skips checks whose variable failed. Fix the first, re-run (🧪 Testing).
An import proposed a replacement A force-new field differs. Restate storage_account_id (example 12).
Wanted prevent_destroy lifecycle is not valid inside a module block. Use a CanNotDelete lock (example 12).

🔗 Related Docs

💙 "Infrastructure as Code should be standardized, consistent, and secure."