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). Targetshashicorp/azurerm ~> 4.0.
- 🎬 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, orContributorplusUser Access Administrator: a caller with onlyContributorcannot write that role assignment, so they will create an account that cannot index anything. - 🔴 Both
identityandstorageare required blocks, so there is no empty call to harden — the module defaults the identity toSystemAssigned, which needs nothing from the caller and involves no credential. - 🔒
public_network_accessdefaults toDisabledhere, diverging from the provider'sEnabled, 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_idshould be one ofidentity.identity_ids, which spans two variables and so cannot be validated. The module emitsstorage_access_identity_is_assigned_to_accountinstead 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.
If this module saves you time, please consider supporting its continued development:
- ⭐ Star the repository on GitHub.
- 🤝 Connect on LinkedIn: linkedin.com/in/microsoftexpert
- ☕ Buy me a coffee: buymeacoffee.com/microsoftexpert
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;
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;
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). |
| 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.
- 🔴
identityandstorageare 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.⚠️ Noneis a valid ARM identity type but not a provider one. This resource always carries an identity.⚠️ public_network_accessdefaults toEnabledin 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 constrainsnameto 3–50 characters of[A-Za-z0-9-], which the provider does not document.⚠️ principal_idis empty whenidentity.typeisUserAssignedalone — see example 5.- ℹ️ The Azure OpenAI connection the ARM API offers is not exposed by the provider, so it cannot be managed here.
lifecycleis not valid inside amoduleblock, so a caller cannot addprevent_destroy(example 12).
| 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, orContributorplusUser Access Administrator— not because the account is privileged, but because creating a working one means writing a role assignment, andContributorcannot (example 3).
✅
Video Indexer Restricted Vieweris the role most people actually want: view videos and their insights, with no ability to change the environment or manage users (example 10).
- 🔴 A blob data role assignment for the account's identity on the storage account —
Storage Blob Data Owner(what the portal assigns) orStorage 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_enabledis left at its defaultfalse(example 7).
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
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.
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 |
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.
identitydefaults toSystemAssignedandpublic_network_access_enableddefaults tofalse, sostorageis 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.
⚠️ storagehas 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 twostoragelines. Changingstorage_account_idreplaces the account; changinguser_assigned_identity_idinside 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 applycompletes, 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 assignStorage Blob Data Contributor. Either works. Prefer Contributor unless you need the Owner-level operations.
⚠️ Contributoron the resource group is not enough to create this assignment. Writing a role assignment needsUser Access AdministratororOwner— which is exactly why Azure's own guidance asks for subscription-levelOwner, orContributorplusUser Access Administrator.
⚠️ Grant it to the right principal.principal_idis the system-assigned identity, and it is empty whenidentity.typeisUserAssignedalone (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✅
SystemAssignedis 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_idreports the system-assigned identity only, and is empty forUserAssignedalone. So a composition that grants the blob data role toprincipal_idunconditionally will silently grant nothing on a user-assigned account. Grant it to the user-assigned identity's own principal instead.
✅
has_system_assigned_identityis 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.
ℹ️
Noneis 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_idspairing are checked on theidentityvariable, since avalidation {}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
SystemAssignedare 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/Disabledstring and not as a..._disabledflag. 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
}
⚠️ falsemeans 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
}
⚠️ Avalidation {}condition may only reference its own variable, so the module cannot checkstorageagainstidentity. 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_idomitted, access falls to the account's system-assigned identity — so the flag istrueonly whenidentity.typeincludesSystemAssigned. With it supplied, the flag istrueonly when that ID is one ofidentity_ids. The comparison is case-insensitive, because Azure Resource IDs are.
🔴
falsemeans 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_idis emitted alongside it, so a reviewer can see which principal needs the blob data role.principal_idalone 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 = falseandwired = trueare the two assertions worth automating. Both describe a state that is easy to get wrong and cheap to check.
🔴
needs_roleis an instruction. The review cannot verify the assignment exists — only remind you that it must (example 3).
✅ Grant humans
Video Indexer Restricted Viewerrather thanReaderon 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
CanNotDeletemanagement lock is worth applying to an account holding indexed insights. It prevents deletion but not the force-new replacement an editedstorage_account_idwould cause.
⚠️ prevent_destroyis not available, becauselifecycleis not valid inside amoduleblock.
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"
⚠️ Restatestorage.storage_account_idexactly. It is force-new, so a mismatch is a replacement rather than an update — and a replacement means re-indexing everything.
⚠️ Also checkpublic_network_access_enabledbefore the first plan. This module defaults it tofalse, so importing an open account without setting it totrueproduces 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/createcycle 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_idused 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_rolereferencesmodule.video_indexer.principal_id, so Terraform creates the account before the assignment without anydepends_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).
| 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.
}| 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_idis an object ID, which is an identifier rather than a credential — the account holds no key or connection string this module could emit.
-
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.
Contributorcannot write role assignments, so a caller granted onlyContributorproduces an account that cannot index — which is the real reason Azure's guidance asks forOwner, orContributorplusUser 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:
storagehas no default because the account cannot exist without storage, andidentitydefaults toSystemAssignedbecause when the choice is which identity rather than whether, the credential-free one is the safe answer. -
public_network_accessis the one field where a secure default is available, and the divergence is deliberate. The provider defaults it toEnabled; 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↔identitypairing is emitted rather than enforced, because Terraform cannot enforce it. Avalidation {}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_accounthandles both the supplied and the omitted case, comparing case-insensitively because Azure Resource IDs are. -
The
identity_idspairing can be placed, so it is — in both directions. The reverse direction matters more than it looks: IDs supplied alongsideSystemAssignedare 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_idbeing empty forUserAssignedalone 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_identityexists 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.
| 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:
Contributoralone 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.
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. ⚠️ Assertstorage_access_identity_is_assigned_to_accountistrue(example 9).⚠️ Assertpublic_network_access_enabledisfalseunless exposure is deliberate.⚠️ Importing? Restatestorage_account_idand check the network default first (example 12).- 💡 A
CanNotDeletelock guards deletion, not replacement.
terraform validate and terraform fmt -check are the offline gate. They confirm:
nameis 3–50 characters of letters, digits and hyphens — an ARM constraint the provider does not publish;resource_group_nameandlocationare non-empty;identity.typeis exactly one of the three provider values, space included;identity.identity_idsis non-empty forUserAssignedand empty forSystemAssigned;- every entry in
identity.identity_idsis an anchored user-assigned identity Resource ID; storage.storage_account_idis 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
providerblock.
💡 These were proved by evaluating the conditions in
terraform consoleinside the module — which does fire root-module variable validations, unliketerraform validateon 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.
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 = truewithstorage_access_identity_id = null: access falls to the system-assigned identity, andidentity_typeincludesSystemAssigned, so the pairing holds (example 9).
🔑
principal_idis the grantee for the blob data role onstorage_account_id(example 3). On aUserAssigned-only account it would be empty (example 5).
🔴
requires_blob_data_role_assignment_on_storage = trueis a reminder, not a status. It does not mean the assignment exists.
| 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). |
azurerm_video_indexer_account— provider documentation, including the identity type values of example 6.- Create an Azure AI Video Indexer account — the permissions and storage requirements of examples 3 and 4.
- Set up Video Indexer with firewall-protected storage — the trusted-services exception of example 8.
Microsoft.VideoIndexer/accountstemplate reference — the ARM name constraint of example 11.- Video Indexer account types — ARM versus the classic accounts retired on 30 June 2024.
- Private endpoints with Video Indexer — reaching a closed account (example 7).
- Sibling modules:
terraform-azurerm-resource-group,terraform-azurerm-storage-account,terraform-azurerm-role-assignments,terraform-azurerm-user-assigned-identity,terraform-azurerm-private-endpoint,terraform-azurerm-monitor-diagnostic-setting. - This module's
SCOPE.md.
💙 "Infrastructure as Code should be standardized, consistent, and secure."

