Skip to content

Latest commit

 

History

History
712 lines (549 loc) · 38.1 KB

File metadata and controls

712 lines (549 loc) · 38.1 KB

☁️ Azure Log Analytics Storage Insights Terraform Module

Lets a Log Analytics workspace read diagnostic data a storage account already holds (azurerm_log_analytics_storage_insights). ⚠️ Requires a full-control storage account key — there is no identity-based alternative. Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources Posture

🧩 Overview

  • 📥 Declares one storage insights record, as a keystone resource named this. It lets a workspace read diagnostic data a storage account already holds — blob containers and Azure tables — so those logs can be queried alongside everything else.
  • 🔑 Requires a plaintext storage_account_key, and the resource offers no identity-based option. That key grants full control of the entire account — read, write and delete on every container and table, not only the ones listed here.
  • 🎯 The mitigation that actually bounds it: a dedicated storage account, so the key in your state reaches that account alone.
  • 🔄 Key rotation is a Terraform change, not a storage-side operation. Until the new value is applied the workspace stops reading.
  • 🚪 If holding that key is unacceptable, this is the wrong resource. terraform-azurerm-monitor-diagnostic-setting sends diagnostics straight to the workspace and uses no key at all — named here rather than buried, because a caller should see it before adopting a credential they did not need.
  • 🧮 Rejects a record that reads neither containers nor tables, and emits sources_read_count because every source listed is billable ingestion.

💡 Why it matters: Most modules in this library can keep secrets out of state. This one cannot — the resource requires the key, so the honest thing is to be explicit about the blast radius and about the two ways to shrink it: use a dedicated account, or use a different resource. Softening that would be the failure mode here.

❤️ Support this project

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


🗺️ Where this fits in the family

flowchart LR
  rg["terraform-azurerm-resource-group"]
  ws["terraform-azurerm-log-analytics-workspace: the family keystone, also owns data-export rules and solutions"]
  cluster["terraform-azurerm-log-analytics-cluster: dedicated cluster for CMK and commitment-tier pricing"]
  cmk["terraform-azurerm-log-analytics-cluster-customer-managed-key: bind the key AFTER the identity is granted on the vault"]
  qp["terraform-azurerm-log-analytics-query-pack"]
  qpq["terraform-azurerm-log-analytics-query-pack-query: name is a GUID, display_name is the label"]
  ls["terraform-azurerm-log-analytics-linked-service: Automation Account OR Cluster, two unrelated links in one resource"]
  lsa["terraform-azurerm-log-analytics-linked-storage-account: redirect ONE data class, by Resource ID, NO keys"]
  si["terraform-azurerm-log-analytics-storage-insights: READ storage. REQUIRES a full-control account KEY."]
  ss["terraform-azurerm-log-analytics-saved-search: query or reusable FUNCTION. Every field force-new."]
  wt["terraform-azurerm-log-analytics-workspace-table: per-table retention and PLAN. The only one of the three with plan."]
  wtcl["terraform-azurerm-log-analytics-workspace-table-custom-log: CREATES a _CL table and owns its columns"]
  wtms["terraform-azurerm-log-analytics-workspace-table-microsoft: six Microsoft tables only. NO plan argument."]
  dwe["terraform-azurerm-log-analytics-datasource-windows-event: LEGACY agent path"]
  dwp["terraform-azurerm-log-analytics-datasource-windows-performance-counter: LEGACY agent path"]
  aa["terraform-azurerm-automation-account"]
  sa["terraform-azurerm-storage-account"]
  kv["terraform-azurerm-key-vault: soft delete AND purge protection REQUIRED"]
  dcr["terraform-azurerm-monitor-data-collection-rule: the MODERN replacement for both datasource modules, and how data reaches a _CL table"]
  ds["terraform-azurerm-monitor-diagnostic-setting: the KEYLESS alternative to storage-insights"]

  rg -->|"resource_group_name, location"| ws
  ws -->|"id, BY ID"| lsa
  ws -->|"id, BY ID"| si
  ws -->|"id as log_analytics_workspace_id"| ss
  ws -->|"id, BY ID"| wt
  ws -->|"id, BY ID"| wtcl
  ws -->|"id, BY ID"| wtms
  ws -->|"id, BY ID"| ls
  ws -->|"name plus resource_group_name, BY NAME"| dwe
  ws -->|"name plus resource_group_name, BY NAME"| dwp
  qp -->|"id, BY ID"| qpq
  aa -->|"id as read_access_id"| ls
  cluster -->|"id as write_access_id, workspace sku must be LACluster"| ls
  cluster -->|"id, plus identity_principal_id feeds the vault grant"| cmk
  kv -->|"key versionless_id, prefer unpinned"| cmk
  sa -->|"ids, no keys"| lsa
  sa -->|"id PLUS a full-control account key"| si
  dcr -->|"prefer this for anything new"| dwe
  dcr -->|"prefer this for anything new"| dwp
  dcr -->|"custom stream writes into the table"| wtcl
  ds -->|"prefer this to avoid the key"| si
  wt -->|"OVERLAP: both own retention on the same table. Pick ONE owner."| wtms

  classDef me fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class ws keystone;
  class cluster,qp,ls,lsa,si,ss,wt,wtcl,wtms,cmk,qpq,dwe,dwp me;
  class rg,aa,sa,kv,dcr,ds sib;
Loading

🧬 What this module builds

flowchart TB
  what["Lets a workspace READ diagnostic data a storage account ALREADY holds: blob containers and Azure tables"]
  key["REQUIRES A PLAINTEXT STORAGE ACCOUNT KEY, and there is NO identity-based alternative on this resource"]
  full["that key grants FULL CONTROL of the ENTIRE account: read, write and delete every container and table, not only the ones listed here"]
  state["so the state backend must be treated as holding a storage account key. sensitive=true keeps it out of plan output and CI logs; nothing keeps it out of STATE."]
  dedicated["MITIGATION THAT ACTUALLY BOUNDS IT: use a DEDICATED storage account, so the key reaches that account alone"]
  rotate["and rotation is a TERRAFORM CHANGE here, not a storage-side operation: until the new value is applied the workspace stops reading"]
  alt["IF THAT IS UNACCEPTABLE, THIS IS THE WRONG RESOURCE: send diagnostics straight to the workspace with terraform-azurerm-monitor-diagnostic-setting, which uses NO key"]
  contrast["contrast the sibling linked-storage-account: it references accounts BY RESOURCE ID ONLY, no keys at all"]
  atleast["at least one of blob_container_names or table_names must be non-empty: a record reading NEITHER ingests nothing while still requiring the key in state"]
  cycle["that check lives on blob_container_names and reads table_names one-directionally, to avoid a validation cycle"]
  count["sources_read_count is emitted because every listed container and table is BILLABLE ingestion"]
  fn["name, resource_group_name and workspace_id are force-new; storage_account_id is NOT, on 4.x"]
  this["terraform-azurerm-log-analytics-storage-insights"]
  keystone["azurerm_log_analytics_storage_insights.this"]

  what -->|"read this first"| key
  key -->|"the consequence"| full
  full -->|"and therefore"| state
  dedicated -->|"do this"| full
  rotate -->|"operational cost"| key
  alt -->|"the way out"| key
  contrast -->|"same family, different contract"| key
  atleast -->|"caught at plan"| this
  cycle -->|"how"| atleast
  count -->|"cost review"| this
  fn -->|"lifecycle"| this
  state -->|"disclosed, not softened"| this
  this -->|"creates"| keystone

  classDef me fill:#0078D4,stroke:#004578,color:#fff;
  classDef keystone fill:#004578,stroke:#001f3f,color:#fff;
  classDef warn fill:#8a1c1c,stroke:#5c1010,color:#fff;
  classDef sib fill:#eef2f7,stroke:#b8c4d0,color:#1b1b1b;
  class this me;
  class keystone keystone;
  class key,full,state warn;
  class what,dedicated,rotate,alt,contrast,atleast,cycle,count,fn sib;
Loading

Resource inventory

Resource Count Notes
azurerm_log_analytics_storage_insights.this 1 The keystone. Holds a full-control storage account key in state, by necessity. No tags on this type.

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
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:

  • storage_account_key is required and marked sensitive by the provider. There is no identity or managed-identity option on this resource type.
  • The key is account-wide. It grants read/write/delete across the whole storage account regardless of which containers and tables the record names.
  • name, resource_group_name and workspace_id are force-new; storage_account_id is NOT on the pinned ~> 4.0 line. The provider writes ForceNew: features.FivePointOh(), and that helper reads an environment variable that is unset by default, so the flag evaluates false. Repointing the record at a different storage account is an in-place update today. It becomes force-new under azurerm 5.0 -- and ForceNew appears nowhere in the binary schema, so this is only visible in the provider's source.
  • blob_container_names and table_names are both optional in the schema, so a record that reads nothing is accepted by the provider. This module rejects it at plan.
  • The resource type supports no tags.
  • Deleting the record stops the workspace reading; ingested data remains for the workspace's retention period and the storage account is untouched.

🔑 Required Azure RBAC Roles / Permissions

Scope Role / permission Why
The workspace Log Analytics Contributor, or a custom role with Microsoft.OperationalInsights/workspaces/storageInsightConfigs/* The record is a child of the workspace.
The storage account Microsoft.Storage/storageAccounts/listKeys/action Only if the key is read with a data source rather than supplied from Key Vault.

⚠️ Note what the resource itself ends up holding is broader than anything in this table: a full-control account key. The table describes what the caller needs in order to deploy, not what the deployed record can reach.

Azure Prerequisites

  • The Microsoft.OperationalInsights resource provider registered on the subscription.
  • An existing Log Analytics workspace.
  • An existing storage account already receiving diagnostic data — preferably one dedicated to this purpose.
  • The account key available out of band (a data "azurerm_storage_account" read, or a Key Vault secret).
  • At least one container or table name to read.

📁 Module Structure

terraform-azurerm-log-analytics-storage-insights/
├── providers.tf   # required_version + the pinned azurerm provider. No provider block.
├── variables.tf   # name/rg/workspace/account, the required key, containers and tables, timeouts
├── main.tf        # the keystone azurerm_log_analytics_storage_insights.this
├── outputs.tf     # id, name, workspace, account, what is read, sources_read_count
├── README.md      # this document
├── SCOPE.md       # the cross-module contract
├── LICENSE        # MIT
└── .gitignore

⚙️ Quick Start

provider "azurerm" {
  features {}
}

# The key is not this module's to fetch — read it, or take it from Key Vault.
data "azurerm_storage_account" "diag" {
  name                = module.st_diag.name
  resource_group_name = module.rg.name
}

module "storage_insights" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-log-analytics-storage-insights.git?ref=v1.0.0"

  name                = "diag-insights"
  resource_group_name = module.rg.name
  workspace_id        = module.law.id

  storage_account_id  = module.st_diag.id
  storage_account_key = data.azurerm_storage_account.diag.primary_access_key

  blob_container_names = ["insights-logs-auditlogs"]
}

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

⚠️ After this applies, your state file contains a key that can delete everything in module.st_diag. Read example 2 before adopting that.

🔌 Cross-Module Contract

Consumes

Input Type Source module
resource_group_name string terraform-azurerm-resource-groupname
workspace_id string terraform-azurerm-log-analytics-workspaceid
storage_account_id string terraform-azurerm-storage-accountid
storage_account_key string 🔒 a data "azurerm_storage_account" read, or Key Vault
blob_container_names / table_names set(string) caller decision — at least one non-empty

Emits

Output Description Consumed by
id The record's Resource ID. imports, policy checks
name The record name. Force-new. review
workspace_id The reading workspace. review
storage_account_id ⚠️ The account the state-held key controls. governance review
blob_container_names / table_names What is read. review, cost review
sources_read_count Derived — total sources, all billable. cost review

🔒 The key is deliberately not emitted. This module already holds it in state by necessity; re-emitting it would widen that exposure to every consumer.

📚 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 "kv_id" {
  description = "id of an existing kv that these examples reference but do not create."
  type        = string
}

variable "some_resource_id" {
  description = "id of an existing some resource that these examples reference but do not create."
  type        = string
}

variable "st_shared_id" {
  description = "id of an existing st shared that these examples reference but do not create."
  type        = string
}
1 · The keyless alternative — consider this first
# NOT this module. No key anywhere.
module "diag_to_law" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-diagnostic-setting.git?ref=v1.0.0"

  name                       = "audit-to-law"
  target_resource_id         = var.some_resource_id
  log_analytics_workspace_id = module.law.id
}

💡 If the goal is "get these diagnostics into the workspace", a diagnostic setting sends them directly and uses no credential at all. This module exists for the different case: data that is already in storage and needs reading from there — a migration, a third-party writer, or a retention archive you did not control the writing of.

🔒 Reaching for this module when a diagnostic setting would do means adopting a full-control account key for nothing. That is why this example is first.

2 · What the key actually grants
The record names:      blob_container_names = ["insights-logs-auditlogs"]
The key can reach:     EVERY container and EVERY table in the account
                       read, write AND delete

⚠️ There is no scoping relationship between the two. The account key is not narrowed by what the record lists — it is simply an account key, held in your Terraform state.

🔒 sensitive = true on the variable keeps it out of plan output and CI logs, which is worth having. It does not keep it out of state, and nothing can for this resource. Treat the state backend as a credential store: restrict who can read it, and prefer a remote backend with access logging.

3 · Use a dedicated storage account
# ✅ A dedicated account: the key reaches only diagnostic data.
module "st_diag" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"

  name                = "stdiagauditprod"
  resource_group_name = module.rg.name
  location            = module.rg.location

  containers = {
    auditlogs = {}
  }
}
# ⚠️ A general-purpose account: the key now also reaches application data,
#    backups, and anything else that happens to live there.
storage_account_id = var.st_shared_id

💡 This is the single mitigation that meaningfully bounds the exposure, and it costs almost nothing. A storage account is cheap; a key that can delete your application's blobs is not.

4 · Rotation is a Terraform change
1. Regenerate key1 on the storage account.
2. The workspace STOPS READING immediately — the stored key is now invalid.
3. Apply the new value through Terraform.
4. Reading resumes.

⚠️ Step 2 is the part that surprises people. On most Azure resources a key rotation is invisible to Terraform; here the key is configuration, so rotation without an apply is an outage of the ingestion path.

💡 If the account has two keys, rotate the one not in use, apply, then rotate the other. That reduces step 2 to nothing. Reading the value from data "azurerm_storage_account" does not help by itself, because the data source is refreshed only when Terraform runs.

5 · Where the key comes from — data source
data "azurerm_storage_account" "diag" {
  name                = module.st_diag.name
  resource_group_name = module.rg.name
}

# ...
storage_account_key = data.azurerm_storage_account.diag.primary_access_key

ℹ️ terraform-azurerm-storage-account deliberately does not emit access keys — this suite's modules do not emit secrets — so the data source is the supported read path. It requires Microsoft.Storage/storageAccounts/listKeys/action.

6 · Where the key comes from — Key Vault
data "azurerm_key_vault_secret" "diag_key" {
  name         = "stdiagauditprod-key1"
  key_vault_id = var.kv_id
}

# ...
storage_account_key = data.azurerm_key_vault_secret.diag_key.value

💡 Prefer this when the identity running Terraform should not hold listKeys on storage, or when the account lives in another subscription. The trade-off is that the vault copy can drift from the real key after a rotation — which then presents as example 4's outage.

🔒 Either path ends with the key in state. The vault narrows who can read the key, not where it ends up.

7 · Reading Azure tables as well as blobs
module "storage_insights" {
  # ...
  blob_container_names = ["insights-logs-auditlogs", "insights-logs-signinlogs"]
  table_names          = ["WADMetricsPT1MP10DV2S20240101"]
}

ℹ️ Both are optional individually; at least one must be non-empty. Tables are the older diagnostics format — worth checking whether the data you want is in blobs before adding table reads you do not need.

8 · A record that reads nothing is rejected
# ❌ Accepted by the provider. Rejected here.
module "storage_insights" {
  name                = "diag-insights"
  resource_group_name = module.rg.name
  workspace_id        = module.law.id
  storage_account_id  = module.st_diag.id
  storage_account_key = data.azurerm_storage_account.diag.primary_access_key
  # no containers, no tables
}
Error: Invalid value for variable

  At least one of blob_container_names or table_names must be non-empty. A storage
  insights record that reads neither ingests nothing, while still requiring a
  full-control storage account key to exist in state.

💡 This is the worst trade the module can make — all of the exposure, none of the benefit — so it is caught at plan rather than left to be discovered.

ℹ️ The check lives on blob_container_names and reads table_names one-directionally. Terraform rejects validations that reference each other across variables, so splitting them would produce a cycle.

9 · Every listed source is billable
output "insights_cost_shape" {
  value = module.storage_insights.sources_read_count
}

💡 Each container and table read is ingestion you pay for at the workspace's rate. A count that is larger than you expected usually means someone added a container to "see what's in it" and left it.

⚠️ Adding a source is a small diff with an ongoing cost. Worth a line in review rather than a rubber stamp.

10 · Contrast with the linked-storage-account sibling
storage-insights (this)        workspace READS data the account already holds.
                               Requires a FULL-CONTROL ACCOUNT KEY.

linked-storage-account         workspace WRITES one class of its data TO the account.
                               Accounts by RESOURCE ID ONLY. No key.

⚠️ Same family, opposite directions, and very different security contracts. Assuming these two work alike is the mistake to avoid: one of them puts a credential in your state and the other does not.

ℹ️ They are also not alternatives — a workspace can do both, for different reasons.

11 · Force-new on the address fields — but not on the storage account
  ~ resource "azurerm_log_analytics_storage_insights" "this" {
      ~ storage_account_id = ".../stdiagold" -> ".../stdiagnew" # forces replacement
    }

ℹ️ name, resource_group_name and workspace_id are force-new; storage_account_id is NOT on the pinned ~> 4.0 line. The provider writes ForceNew: features.FivePointOh(), and that helper reads an environment variable that is unset by default, so the flag evaluates false. Repointing the record at a different storage account is an in-place update today. It becomes force-new under azurerm 5.0 -- and ForceNew appears nowhere in the binary schema, so this is only visible in the provider's source. Replacement, where it does happen, is cheap: the record is configuration rather than data, so reading pauses briefly and loses nothing.

💡 storage_account_key, storage_account_id, blob_container_names and table_names all update in place, which is why rotation (example 4) is an update rather than a rebuild.

12 · What a governance review should assert
output "insights_posture" {
  value = {
    account   = module.storage_insights.storage_account_id
    sources   = module.storage_insights.sources_read_count
    blobs     = module.storage_insights.blob_container_names
    tables    = module.storage_insights.table_names
  }
}

🔒 The question a reviewer should ask is not visible in these outputs and has to be asked directly: is account dedicated to diagnostics? If it holds anything else, the key in state reaches that too.

💡 A useful policy rule: any module call of this type must reference a storage account whose name matches your diagnostics naming convention. That is enforceable in CI and catches the general-purpose-account case.

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-observability-prod"
  location = "eastus"
}

module "law" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-log-analytics-workspace.git?ref=v1.0.0"

  name                = "law-observability-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location
}

# 1 · A storage account DEDICATED to diagnostics — the mitigation that bounds the key.
module "st_diag" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-storage-account.git?ref=v1.0.0"

  name                = "stdiagauditprod"
  resource_group_name = module.rg.name
  location            = module.rg.location

  containers = {
    auditlogs = {}
  }
}

# 2 · The key is not this module's to fetch.
data "azurerm_storage_account" "diag" {
  name                = module.st_diag.name
  resource_group_name = module.rg.name
}

# 3 · Read what the account already holds. This is the module.
module "storage_insights" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-log-analytics-storage-insights.git?ref=v1.0.0"

  name                = "diag-insights"
  resource_group_name = module.rg.name
  workspace_id        = module.law.id

  storage_account_id  = module.st_diag.id
  storage_account_key = data.azurerm_storage_account.diag.primary_access_key

  blob_container_names = ["auditlogs"]
}

# 4 · For anything NEW, prefer the keyless path — no credential, no state exposure.
module "diag_direct" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-monitor-diagnostic-setting.git?ref=v1.0.0"

  name                       = "keyvault-to-law"
  target_resource_id         = var.kv_id
  log_analytics_workspace_id = module.law.id
}

output "observability" {
  value = {
    reads_from    = module.storage_insights.storage_account_id
    source_count  = module.storage_insights.sources_read_count
  }
}

🔒 What the composition gets right: the account is dedicated, so the state-held key reaches only diagnostic data; the key is read rather than committed; exactly one container is read rather than a speculative list; and anything new goes down the keyless path in step 4.

⚠️ What no composition can fix: the key is in state. That is the resource type's contract, and the only real choices are to bound it (step 1) or avoid it (step 4).

📥 Inputs

Input Type Default Notes
name string Required. Force-new.
resource_group_name string Required. Force-new.
workspace_id string Required. Force-new.
storage_account_id string Required. In-place updatable on ~> 4.0; force-new only under azurerm 5.0. 💡 Use a dedicated account.
storage_account_key string Required. 🔒 Sensitive. Full-control, account-wide. No identity option.
blob_container_names set(string) [] An empty set is legal here — unlike table_names, this field carries no MinItems. Reading neither containers nor tables is reported through the reads_nothing output rather than refused.
table_names set(string) null ⚠️ Defaults to null, not [], and that is deliberate: the provider puts MinItems: 1 on this field and not on blob_container_names, so an explicit empty set fails at terraform validate with "requires 1 item minimum". Omit it to read no tables.
timeouts object(...) null create / read / update / delete.
Full schemas
variable "storage_account_key" {
  type      = string
  sensitive = true
  # 🔒 Required, with NO identity-based alternative on this resource type. Grants FULL CONTROL of the
  #    entire account — every container and table, regardless of what this record lists.
  #    sensitive = true keeps it out of plan output and CI logs. It WILL be in state; that is
  #    unavoidable here, so restrict the state backend accordingly.
  # ⚠️ Rotation is a Terraform change: until the new value is applied, the workspace stops reading.
}

variable "storage_account_id" {
  type = string
  # 💡 Prefer an account DEDICATED to this purpose. Because the key above is account-wide, pointing
  #    this at a general-purpose account means the key in state reaches everything else in it.
}

variable "blob_container_names" {
  type    = set(string)
  default = []
  # ⚠️ At least one of blob_container_names / table_names must be non-empty. The check lives here and
  #    reads table_names one-directionally, because Terraform rejects mutually-referencing validations.
  # 💡 Every source listed is billable ingestion.
}

🧾 Outputs

Output Description Kind
id The Azure Resource ID of the storage insights record, of the form .../workspaces//storageInsightConfigs/ Passthrough
name The storage insights record name Passthrough
resource_group_name The resource group the record was created in Passthrough
workspace_id The Resource ID of the workspace reading the storage account Passthrough
storage_account_id The storage account being read Passthrough
blob_container_names The blob containers the workspace reads Passthrough
table_names The Azure tables the workspace reads Derived
sources_read_count Derived Derived
reads_nothing Derived Passthrough
account_key_persisted_in_state Constant true, and it is the defining fact about this module Constant
key_scope_is_entire_storage_account Constant true Constant
key_rotation_is_invisible_to_plan Constant true Constant

🔒 The key is not emitted. This module holds it in state by necessity; re-emitting it would widen that exposure to every consumer of the module.

🧠 Architecture Notes

  • This is one of the few modules in this library that cannot keep a secret out of state, and that shapes everything about it. The resource requires the key, so the module's job is to state the blast radius plainly and name the two ways to shrink it: bound it with a dedicated account, or avoid it with a diagnostic setting.

  • The key is not scoped by what the record reads. Listing one container does not narrow the credential. That asymmetry is the single most important fact here and the easiest to assume away.

  • Rotation behaves unlike most Azure resources. Elsewhere a key rotation is invisible to Terraform; here the key is configuration, so rotating without applying is an outage of the ingestion path. Two-key alternation removes the gap.

  • A record reading nothing is the worst possible trade — full exposure, zero benefit — and the provider accepts it, because both list fields are optional in the schema. Rejecting it at plan is exactly the kind of thing this library's typing rules exist for.

  • The cross-field check is one-directional by necessity. Terraform rejects validations that reference each other across variables; the error is a cycle rather than a useful message. Putting the check on blob_container_names and reading table_names avoids it — the same pattern used by the Kusto customer-managed-key and Service Bus authorization-rule modules in this suite.

  • The sibling linked-storage-account has the opposite security contract, and the two are easy to conflate because their names are similar and they involve the same two resource types. One takes accounts by Resource ID with no credential; this one takes a key. Documented in both.

  • Force-new is cheap here, and narrower than it looks. Only name, resource_group_name and workspace_id force replacement on ~> 4.0; storage_account_id joins them only under azurerm 5.0. The record is configuration rather than data, so a replacement pauses reading briefly and loses nothing.

  • No tags. The resource type supports none; tag the workspace and the storage account instead.

🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
The credential disclosed as unavoidable, with the blast radius stated rather than softened
Plan output and CI logs key redacted via sensitive = true
State stated plainly as unavoidable exposure; backend restriction recommended
Blast radius a dedicated storage account recommended in the input description itself point at a shared account, knowingly
The alternative monitor-diagnostic-setting named in the overview, not buried adopt the key, knowingly
Useless-but-exposed records rejected at plan when nothing is read
Cost sources_read_count emitted; every source is billable add sources, visibly
Secrets in outputs the key is never emitted
  • Before applying: ask whether a diagnostic setting would do instead. If yes, use that.
  • Before applying: confirm the storage account is dedicated to diagnostics.
  • Before applying: confirm who can read the Terraform state backend.
  • Before rotating a key: plan the apply that follows it, or alternate keys.

🚀 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.
  • ⚠️ Treat the state backend as holding a storage account key. Restrict read access and prefer a backend with access logging.
  • ⚠️ Rotate keys in pairs: regenerate the unused key, apply, then regenerate the other.
  • ℹ️ Adding a container or table is an in-place update with an ongoing ingestion cost.

🧪 Testing

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

  • the five required inputs are present and typed correctly;
  • at least one of blob_container_names / table_names is non-empty;
  • the timeouts value is an object of Go duration strings - but note that a key the type does not declare is SILENTLY DISCARDED rather than refused, so a misspelling here produces no error anywhere and the provider default quietly applies;
  • the module declares no provider block.

What only plan and apply exercise:

  • whether the key is valid and current — a stale key fails at apply;
  • whether the workspace and storage account exist;
  • whether the named containers and tables exist in the account.

What no Terraform command checks, and which matters most here:

  • whether the storage account is dedicated, and therefore whether the state-held key is bounded. That is a review question, and a CI naming-convention rule is the practical way to enforce it.

💬 Example Output

Outputs:

blob_container_names = ["auditlogs"]
id                   = "/subscriptions/00000000-.../workspaces/law-observability-prod/storageInsightConfigs/diag-insights"
name                 = "diag-insights"
sources_read_count   = 1
storage_account_id   = "/subscriptions/00000000-.../storageAccounts/stdiagauditprod"
table_names          = []

💡 sources_read_count = 1 against a dedicated account is the shape to aim for. The key is absent from the outputs by design.

🔍 Troubleshooting

Symptom Cause Fix
Reading stopped with no Terraform change The storage account key was rotated outside Terraform. Apply the new key (example 4).
Apply fails authenticating to storage The key is stale, or belongs to a different account. Re-read the key for the account named in storage_account_id.
Plan rejects the call for reading nothing Both source lists are empty. Name at least one container or table (example 8).
The data source cannot read the key The caller lacks listKeys on the account. Grant it, or take the key from Key Vault (example 6).
Unexpected workspace ingestion cost More sources are read than intended. Check sources_read_count (example 9).
A security review objected to the state contents The state holds a full-control account key. It is unavoidable for this resource. Bound it with a dedicated account (example 3), or move to a diagnostic setting (example 1).
The key can reach unrelated blobs The account is general-purpose, not dedicated. Move diagnostics to a dedicated account and re-point this record.
A small edit replaced the record An address field is force-new. Expected and cheap — reading pauses briefly (example 11).
Copied a line from linked-storage-account and it failed That module takes accounts by Resource ID and no key. Different contract; see example 10.
Named containers exist but no data appears The workspace cannot reach the account, or the container holds no supported diagnostic format. Check the account's network rules and the data's format.

🔗 Related Docs

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