Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

☁️ Azure Microsoft Sentinel Workspace Onboarding Terraform Module

Onboards Microsoft Sentinel onto a Log Analytics workspace — the gate every other Sentinel resource depends on (azurerm_sentinel_log_analytics_workspace_onboarding). Targets hashicorp/azurerm ~> 4.0.

Terraform Provider Module Type Resources CMK

🧩 Overview

  • 🚪 The gate for the whole Sentinel family. Every alert rule, data connector, watchlist and automation rule targets an onboarded workspace, so nothing else can exist first.
  • 🔴 A destroy offboards Sentinel, which stops every detection pointed at the workspace at once.
  • ⚠️ workspace_id takes the ARM Resource ID — and azurerm_log_analytics_workspace also exports an attribute literally called workspace_id, which is the wrong value. A bare GUID is rejected at plan time.
  • 🧭 Emits workspace_id for its eight siblings, which is what makes Terraform order onboarding before rules.
  • 🔴 customer_managed_key_enabled = true is permanent for the workspace — it cannot be onboarded again with false.
  • 🔁 Both arguments are force-new, and there is no update operation.
  • 🔒 No credential is accepted and none is emitted.

💡 Why it matters: Two arguments, and one of them is a decision you cannot take back. The other is a field whose name is the same as the value you must not pass it. Everything else in Sentinel depends on this resource existing, which makes its destroy behaviour the most consequential fact in the family.

📅 Azure portal retirement - March 31, 2027

Microsoft states that after March 31, 2027, Microsoft Sentinel will no longer be supported in the Azure portal and will be available only in the Microsoft Defender portal, and that customers using Microsoft Sentinel in the Azure portal will be redirected to the Defender portal. Since July 2025 many new customers are onboarded and redirected to the Defender portal automatically.

This does not change what this module manages. azurerm_sentinel_* are ARM resources and Terraform talks to ARM, not to a portal, so these resources continue to exist and stay manageable from code across that date. What moves is the management experience - runbooks, screenshots, analyst training, and any procedure that ends in a human clicking through Sentinel in the Azure portal. Plan that transition on its own schedule; this module emits management_moves_to_the_defender_portal so the date reaches plan output and inventory reports rather than living only in documentation.

Reference: https://learn.microsoft.com/azure/sentinel/overview#microsoft-sentinel-in-the-azure-portal-retirement-timeline

❤️ Support this project

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


🗺️ Where this fits in the family

flowchart LR
  law["terraform-azurerm-log-analytics-workspace: Sentinel has no workspace of its own. Everything here lives on a Log Analytics workspace, and its RETENTION decides how far back any detection can look."]
  onboard["terraform-azurerm-sentinel-log-analytics-workspace-onboarding: THE GATE. Nothing else in this family works until it exists, and DESTROYING IT OFFBOARDS SENTINEL from the workspace."]
  wsid["AND WIRE log_analytics_workspace_id FROM THIS MODULE'S workspace_id OUTPUT, not from the workspace module's id. Both carry the same value, but reading it through here makes Terraform ORDER ONBOARDING FIRST. The provider's own examples chain it this way."]
  gotcha["NOTE THE FIELD NAME TRAP: azurerm_log_analytics_workspace exports BOTH id AND an attribute literally named workspace_id. Every module in this family wants the ARM RESOURCE ID, not the GUID. A bare GUID is rejected at plan time."]
  cmk["and customer_managed_key_enabled is PERMANENT FOR THE WORKSPACE: once onboarded with it true, the workspace cannot be onboarded again with it false. It is also force-new, so a plan that looks like an ordinary replacement can leave the workspace offboarded and unable to come back the way you asked."]
  sched["terraform-azurerm-sentinel-alert-rule-scheduled: THE WORKHORSE. Your KQL, on a schedule. query_period must be at least query_frequency or events between runs are never examined."]
  nrt["terraform-azurerm-sentinel-alert-rule-nrt: the same shape MINUS the schedule, evaluated continuously. event_grouping is REQUIRED here, entity mappings cap at 5 combined not 10, and alert_rule_template_version is force-new."]
  nrtq["BUT NRT RESTRICTS THE QUERY: joins, wide unions and long aggregations are the usual casualties, and NOTHING CHECKS THAT AT PLAN TIME. Build it in the analytics-rule wizard first; porting a scheduled rule's query straight in is the common way to get a rule that will not run."]
  ms["terraform-azurerm-sentinel-alert-rule-ms-security-incident: PROMOTES alerts another Microsoft product already raised. Detects nothing itself. product_filter takes LEGACY product names, so Microsoft Defender for Endpoint is REJECTED and Microsoft Defender Advanced Threat Protection is accepted."]
  fusion["terraform-azurerm-sentinel-alert-rule-fusion: Microsoft's MULTI-STAGE attack correlation. Its name argument is DEPRECATED and removed in provider v5.0, so this module neither accepts nor emits it."]
  mlba["terraform-azurerm-sentinel-alert-rule-machine-learning-behavior-analytics: Microsoft's behaviour model. Needs a LEARNING PERIOD, so early silence is not a misconfiguration."]
  ti["terraform-azurerm-sentinel-alert-rule-threat-intelligence: matches logs against threat-intelligence INDICATORS, and supplies none. With no indicator source it is a detection that CANNOT FIRE, and nothing about it looks wrong."]
  tmpl["these three are TEMPLATE-DRIVEN: the alert_rule_template_guid IS the detection, so repointing it is a different detection rather than a retune, and enabled is the only thing that updates in place"]
  anom["terraform-azurerm-sentinel-alert-rule-anomaly-built-in: TOGGLES a rule Microsoft ships. It creates nothing, so REMOVING THIS RESOURCE DISABLES THE RULE rather than releasing it."]
  dup["terraform-azurerm-sentinel-alert-rule-anomaly-duplicate: a TUNED COPY, because a built-in rule's thresholds are read-only. Anything you do not override is INHERITED from the built-in rule."]
  pair["so the usual shape is a PAIR, and it needs a decision the provider will not make: running the original AND a tuned duplicate in Production means the same detection active twice with two sets of thresholds"]
  mode["and BOTH anomaly modules take a REQUIRED mode: Production or FLIGHTING. Flighting runs in evaluation - enabled, configured, and not contributing. Right while tuning, wrong to leave behind."]
  gaps["THE FAMILY'S REAL RISK IS NOT AN OPEN DOOR, IT IS A SILENT DETECTION. enabled false, create_incident_enabled false, suppression_enabled true, mode Flighting, a trigger threshold above zero, a severity_filter missing High - each looks reasonable alone and each leaves a rule that passes a review asking IS IT CONFIGURED and fails one asking DOES IT WORK."]
  emit["which is why every module here emits derived posture flags - is_active, creates_incidents, is_production_mode - and the two query modules gather them into a single detection_gaps output"]

  law -->|"id"| onboard
  cmk -->|"decide before the first apply"| onboard
  onboard -->|"workspace_id"| wsid
  gotcha -->|"read this first"| wsid
  wsid -->|"log_analytics_workspace_id"| sched
  wsid -->|"log_analytics_workspace_id"| nrt
  wsid -->|"log_analytics_workspace_id"| ms
  wsid -->|"log_analytics_workspace_id"| fusion
  wsid -->|"log_analytics_workspace_id"| mlba
  wsid -->|"log_analytics_workspace_id"| ti
  wsid -->|"log_analytics_workspace_id"| anom
  wsid -->|"log_analytics_workspace_id"| dup
  nrtq -->|"unvalidatable"| nrt
  tmpl -->|"shape"| fusion
  tmpl -->|"shape"| mlba
  tmpl -->|"shape"| ti
  anom -->|"settings_definition_id becomes built_in_rule_id"| dup
  pair -->|"disable or flight the original"| dup
  mode -->|"required"| anom
  mode -->|"required"| dup
  gaps -->|"so"| emit
  emit -->|"assert on these"| sched
  emit -->|"assert on these"| nrt

  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 onboard keystone;
  class sched,nrt,ms,fusion,mlba,ti,anom,dup me;
  class law,wsid,gotcha,cmk,nrtq,tmpl,pair,mode,gaps,emit sib;
Loading

🧬 What this module builds

flowchart TB
  gate["READ THIS FIRST: THIS IS THE GATE FOR THE WHOLE SENTINEL FAMILY. Every alert rule, data connector and watchlist targets a workspace that has been onboarded, so nothing else works until this resource exists."]
  destroy["AND DESTROYING IT OFFBOARDS SENTINEL FROM THE WORKSPACE, which stops every rule, connector and watchlist pointed at it. There is no name argument and one onboarding per workspace, always named default in ARM."]
  field["THE FIELD NAME IS A TRAP: workspace_id takes the workspace's ARM RESOURCE ID. But azurerm_log_analytics_workspace ALSO exports an attribute literally named workspace_id, which is the customer GUID. Passing that is the single most likely mistake here, so a bare GUID is rejected at plan time."]
  emit["and this module EMITS workspace_id so every sibling reads it from here. That is not cosmetic: chaining through this output makes Terraform ORDER ONBOARDING FIRST, which is what the provider's own examples do. Wiring the workspace module's id directly works and leaves the ordering to chance."]
  cmk["customer_managed_key_enabled IS PERMANENT FOR THE WORKSPACE. The provider states it plainly: once onboarded with it true, the workspace CANNOT be onboarded again with it false."]
  trap["and it is FORCE-NEW, so a change plans as destroy-and-recreate: the destroy offboards Sentinel, then the recreate with false is REJECTED by the service. A plan that looks like an ordinary replacement can leave the workspace offboarded and unable to come back the way you asked."]
  invert["SO THIS MODULE KEEPS THE PROVIDER'S false DEFAULT, against this suite's usual convention of defaulting to the more protective value. A CMK-by-default would make the empty call take an IRREVERSIBLE action, and would fail for anyone who has not already enabled CMK on the workspace and granted the Key Vault access - neither of which is checkable from here."]
  noupd["there is NO update operation at all: both arguments are force-new, so the schema omits the update timeout and declaring one fails at plan"]
  this["terraform-azurerm-sentinel-log-analytics-workspace-onboarding"]
  keystone["azurerm_sentinel_log_analytics_workspace_onboarding.this"]

  gate -->|"and"| destroy
  destroy -->|"scope"| this
  field -->|"so"| emit
  emit -->|"wiring"| this
  cmk -->|"and worse"| trap
  trap -->|"therefore"| invert
  invert -->|"deliberate divergence"| this
  noupd -->|"lifecycle"| this
  this -->|"onboards Sentinel onto the workspace"| keystone

  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 this me;
  class keystone keystone;
  class gate,destroy,field,emit,cmk,trap,invert,noupd sib;
Loading

Resource inventory

Resource Count Notes
azurerm_sentinel_log_analytics_workspace_onboarding.this 1 The keystone. One per workspace, always default in ARM.
timeouts block 0..1 Create, read and delete only — there is no update operation.

Two inputs, no nested blocks but timeouts, and no tags.

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Azure resource providers Microsoft.OperationalInsights (API 2022-11-01) for the workspace; the onboarding state is a Microsoft.SecurityInsights child
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:

  • workspace_id takes the workspace's ARM Resource ID, not the customer GUID that azurerm_log_analytics_workspace exports under the same field name.
  • Both arguments are force-new. The provider's wording for a workspace_id change is "forces the Log Analytics Workspace off the board and onboard again".
  • 🔴 Once a workspace is onboarded with customer_managed_key_enabled = true, it cannot be onboarded again with false. The constraint belongs to the workspace, not to the Terraform resource, so destroying and recreating does not clear it.
  • There is no update timeout in the schema — declaring one fails at plan.
  • The ARM name is always default, so there is no name argument. The documented import path ends /onboardingStates/defaults.
  • No tags.
  • lifecycle is not valid inside a module block, so a caller cannot add prevent_destroy.

🔑 Required Azure RBAC Roles / Permissions

Operation Role Scope
Onboarding Sentinel onto the workspace Microsoft Sentinel Contributor plus Log Analytics Contributor the workspace, or the resource group containing it
Offboarding the same the same

Contributor or Owner at the same scope also work, and are broader than needed.

⚠️ This is a narrower-looking permission than it behaves like. Whoever holds it can offboard Sentinel, which silences every detection in the workspace in one operation.

Azure Prerequisites

  • An existing Log Analytics workspace, with retention set deliberately — it bounds what every detection in the workspace can look back at.
  • The Microsoft.SecurityInsights resource provider registered on the subscription.
  • If and only if you want CMK: customer-managed keys already enabled on the workspace, and the Key Vault already granting the service access. Both must be in place before the first apply, because the choice cannot be reversed.

📁 Module Structure

terraform-azurerm-sentinel-log-analytics-workspace-onboarding/
├── providers.tf   # required_version + the pinned azurerm provider. No provider block.
├── variables.tf   # workspace_id, customer_managed_key_enabled, timeouts (no update)
├── main.tf        # the keystone, one resource
├── outputs.tf     # id, workspace_id for the siblings, and the CMK + onboarded flags
├── README.md      # this document
├── SCOPE.md       # the cross-module contract and the CMK rationale
├── LICENSE        # MIT
└── .gitignore

⚙️ Quick Start

provider "azurerm" {
  features {}
}

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

  # ⚠️ The workspace's ARM Resource ID — its `id`, NOT its `workspace_id`. See example 2.
  workspace_id = module.law.id

  # Left at false. 🔴 Setting true is permanent for this workspace — see example 4.
  # customer_managed_key_enabled = true
}

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

⚠️ Then wire every Sentinel rule from module.sentinel.workspace_id, not from module.law.id — example 3 explains why that matters.

🔌 Cross-Module Contract

Consumes

Input Type Source module
workspace_id string terraform-azurerm-log-analytics-workspaceid
customer_managed_key_enabled bool caller decision — permanent for the workspace

Emits

Output Description Consumed by
id The onboarding state's Resource ID, ending /onboardingStates/default. review, imports
workspace_id The onboarded workspace's ARM Resource ID. all eight sibling Sentinel modules, as log_analytics_workspace_id
customer_managed_key_enabled The permanent CMK decision. security review
sentinel_is_onboarded Always true while the resource exists. dependency review

No credential is accepted and none is emitted.

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

variable "law_prod_id" {
  description = "id of an existing law prod that these examples reference but do not create."
  type        = string
}
1 · 🚪 Why everything else waits for this
Every Sentinel resource in this suite takes a log_analytics_workspace_id:

  sentinel-alert-rule-scheduled           sentinel-alert-rule-anomaly-built-in
  sentinel-alert-rule-nrt                 sentinel-alert-rule-anomaly-duplicate
  sentinel-alert-rule-ms-security-incident        sentinel-watchlist
  sentinel-alert-rule-fusion              sentinel-metadata
  sentinel-alert-rule-machine-learning-behavior-analytics
  sentinel-alert-rule-threat-intelligence          ...and every data connector

None of them work against a workspace that has not been onboarded.

⚠️ And a destroy here offboards Sentinel, which takes every one of them out of service at once. Not deletes — the resources may still exist in your state — but the workspace is no longer a Sentinel workspace, so nothing detects anything.

💡 That makes this the resource to protect, and a caller cannot add prevent_destroy because lifecycle is not valid inside a module block. A CanNotDelete lock is the available control:

resource "azurerm_management_lock" "sentinel" {
  name       = "sentinel-no-offboard"
  scope      = module.law.id
  lock_level = "CanNotDelete"
  notes      = "Deleting the Sentinel onboarding silences every detection in this workspace"
}

ℹ️ sentinel_is_onboarded is emitted as a constant true so that the family's central dependency appears in a state review at all.

2 · ⚠️ The field name is the same as the value you must not pass
# ✅ Correct — the workspace's ARM Resource ID.
workspace_id = module.law.id
# or, without a module:
workspace_id = azurerm_log_analytics_workspace.example.id
# ❌ The trap. Rejected at plan time.
workspace_id = azurerm_log_analytics_workspace.example.workspace_id

🔴 azurerm_log_analytics_workspace exports BOTH. Its id is the ARM Resource ID; its workspace_id attribute is the workspace's customer GUID. This module's argument is named workspace_id and wants the other one.

Error: Invalid value for variable

  workspace_id must be a Microsoft.OperationalInsights/workspaces ARM Resource ID
  with nothing appended — that is the workspace's `id`, not its `workspace_id`
  attribute. A bare GUID is the workspace's customer ID and is not accepted here.

💡 One validation is worth it here precisely because the names collide: without it, a plausible-looking configuration fails at apply with a service error that does not mention either field.

ℹ️ Every one of the eight sibling modules carries the same check on its log_analytics_workspace_id, for the same reason.

3 · 🧭 Wire the siblings from this module's output, not from the workspace
# ✅ This creates the dependency edge.
module "rule" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-sentinel-alert-rule-scheduled.git?ref=v1.0.0"

  log_analytics_workspace_id = module.sentinel.workspace_id
  display_name                 = "display-example"
  name                         = "rule-example"
  query                        = "query"
  severity                     = "severity"
}
# ⚠️ Same value. No dependency on onboarding.
log_analytics_workspace_id = module.law.id

💡 Both carry an identical string, so both apply cleanly once Sentinel is onboarded. The difference is ordering: reading it through this module makes Terraform onboard Sentinel before creating rules. Reading it from the workspace leaves the two unordered, and a first apply can attempt to create a rule on a workspace that is not yet a Sentinel workspace.

ℹ️ The provider's own examples chain it this way — its Fusion rule example takes azurerm_sentinel_log_analytics_workspace_onboarding.example.workspace_id, not the workspace's id.

💡 This is the one place in the family where an output exists as much for its effect on the dependency graph as for its value.

4 · 🔴 The CMK decision cannot be taken back
customer_managed_key_enabled = true # 🔴 permanent for this workspace

🔴 The provider states it plainly: once a workspace is onboarded with customer_managed_key_enabled set to true, it cannot be onboarded again with it set to false.

⚠️ And the field is force-new, which makes the failure mode worse than it looks. Flipping truefalse plans as an ordinary replacement:

  # module.sentinel.azurerm_sentinel_log_analytics_workspace_onboarding.this must be replaced
  -/+ resource "azurerm_sentinel_log_analytics_workspace_onboarding" "this" {
        ~ customer_managed_key_enabled = true -> false # forces replacement

🔴 That plan destroys the onboarding — offboarding Sentinel — and then asks the service to onboard again with false, which it refuses. The workspace ends up offboarded and unable to return to the state you just requested.

💡 So: decide before the first apply, complete the prerequisites first, and treat the value as immutable afterwards. customer_managed_key_enabled is emitted so the decision is visible in a state review rather than inferred from the inputs.

5 · 🧱 Why this module does *not* default CMK on
customer_managed_key_enabled = false # this module's default, matching the provider

ℹ️ This is a deliberate departure from this suite's usual convention, which is to default to the more protective value and make the caller type the risky one. Two reasons, and both are about what the empty call does:

🔴 It would take an irreversible action. A secure default is only secure if it is also reversible — otherwise the first apply of an unreviewed configuration makes a permanent decision on the caller's behalf.

⚠️ It would fail for most callers. CMK requires customer-managed keys already enabled on the Log Analytics workspace and the Key Vault already granting the service access. Neither is checkable from here, so a true default would make the minimal call fail at apply for anyone who has not done both.

💡 The general principle this case illustrates: prefer the protective default unless it is irreversible or unachievable from the module's own inputs. Where it is either, document loudly and emit the setting instead — which is what this module does.

6 · 🔁 No update operation at all
Force-new: workspace_id  customer_managed_key_enabled
Updatable: nothing
timeouts = { create = "45m", read = "5m", delete = "30m" } #
timeouts = { create = "45m", update = "30m" }              # ❌ fails at plan

ℹ️ Both arguments are force-new, so the resource has no update path and the provider's schema omits the update timeout. Declaring one is a validate-time error, which is a confusing message to meet without knowing why the field is absent.

⚠️ So every change to this resource is a destroy-and-recreate, and the destroy step offboards Sentinel (example 1). There is no such thing as a small edit here.

💡 The provider's own default for create is 30 minutes — onboarding provisions the Sentinel solution onto the workspace, so it is slower than a typical control-plane call.

7 · Retention is the setting this module cannot see
module "law" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-log-analytics-workspace.git?ref=v1.0.0"

  name                = "law-sentinel-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location
  sku                 = "PerGB2018"
  retention_in_days   = 90 # ⚠️ bounds every detection in the workspace
}

⚠️ Sentinel has no storage of its own. Every detection queries the Log Analytics workspace, so the workspace's retention is a hard ceiling on how far back any rule can look — and on how far back an analyst can investigate once an incident is raised.

💡 A scheduled rule with a query_period longer than the workspace retains data finds nothing in the missing span, and nothing reports the mismatch. This module cannot check it either.

ℹ️ Retention is also the main cost lever for a Sentinel deployment, which is why it belongs in the same conversation as onboarding rather than being treated as a workspace detail.

8 · Onboarding several workspaces
locals {
  workspaces = {
    prod = var.law_prod_id
    dev  = var.law_dev_id
  }
}

module "sentinel" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-sentinel-log-analytics-workspace-onboarding.git?ref=v1.0.0"
  for_each = local.workspaces

  workspace_id = each.value
}

# Then each rule targets one workspace's onboarding:
module "rule_prod" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-sentinel-alert-rule-scheduled.git?ref=v1.0.0"

  log_analytics_workspace_id = module.sentinel["prod"].workspace_id
  display_name                 = "display-example"
  name                         = "rule-prod-example"
  query                        = "query"
  severity                     = "severity"
}

ℹ️ for_each lives in the caller — the resource is one per workspace, and the module reflects that.

💡 Keying by environment name rather than by workspace ID keeps the keys stable: a workspace replaced for unrelated reasons does not re-index the others.

⚠️ Detections do not span workspaces. Each rule belongs to exactly one, so a multi-workspace estate means duplicating every rule per workspace — which is the argument for consolidating workspaces before consolidating detections.

9 · What a review should assert
output "sentinel_posture" {
  value = {
    id        = module.sentinel.id
    workspace = module.sentinel.workspace_id
    cmk       = module.sentinel.customer_managed_key_enabled # permanent, either way
    onboarded = module.sentinel.sentinel_is_onboarded         # always true
  }
}

🔒 cmk is the one to record. It is the only permanent decision this module makes, and once made it constrains the workspace for as long as the workspace exists — so it belongs in whatever record outlives the Terraform configuration.

ℹ️ onboarded is a constant true. It is in the outputs so the question "what does destroying this take with it?" has an answer in the state review rather than only in this document.

💡 Nothing here is sensitive, and nothing needs to be. No module in the Sentinel family takes a credential — a pleasant contrast with much of this suite.

10 · Importing a workspace onboarded from the portal
terraform import 'module.sentinel.azurerm_sentinel_log_analytics_workspace_onboarding.this' \
  "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-sentinel/providers/Microsoft.OperationalInsights/workspaces/law-sentinel-prod/providers/Microsoft.SecurityInsights/onboardingStates/default"

⚠️ Check customer_managed_key_enabled against reality before the first plan. If the workspace was onboarded with CMK and your configuration says false, the plan proposes a replacement — which would offboard Sentinel and then be refused (example 4).

💡 Enabling Sentinel from the portal is the common path, so importing is often more appropriate than creating. Import, confirm an empty plan, then add the rules.

ℹ️ The provider documents the import path with a trailing onboardingStates/defaults. If an import does not resolve, that final segment is the first thing to check.

11 · Offboarding on purpose, if you ever have to
# What a destroy of this module actually does:
#   - removes the Sentinel onboarding state from the workspace
#   - the workspace stops being a Sentinel workspace
#   - every rule, connector and watchlist pointed at it stops working
#   - the Log Analytics workspace and its data are NOT deleted

ℹ️ Offboarding is not data loss. The workspace, its tables and its retained data survive; what goes away is Sentinel's layer on top. That distinction is worth knowing before an incident, because "we destroyed the onboarding" sounds worse than it is and is still an outage of every detection.

⚠️ But the Terraform resources for the rules do not disappear with it. They may remain in state, referring to a workspace that is no longer onboarded — so a subsequent apply can fail on resources that looked fine yesterday. Remove the rules from the configuration before the onboarding, not after.

💡 The safe order for a deliberate offboarding:

1. Confirm nothing depends on the detections - this is a security control, so say so out loud.
2. Remove or destroy the rules, connectors and watchlists FIRST.
3. Remove the CanNotDelete lock.
4. Destroy the onboarding.
5. If you intend to re-onboard, note the CMK setting first (example 4) - it is permanent.

⚠️ Step 5 is the one that bites. If the workspace was onboarded with CMK, re-onboarding must also use CMK. There is no going back to false.

12 · 🏗️ 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-sentinel-prod"
  location = "eastus"
}

# ── The workspace: retention bounds every detection (example 7) ────────────────
module "law" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-log-analytics-workspace.git?ref=v1.0.0"

  name                = "law-sentinel-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location
  sku                 = "PerGB2018"
  retention_in_days   = 90
}

# ── The gate ──────────────────────────────────────────────────────────────────
module "sentinel" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-sentinel-log-analytics-workspace-onboarding.git?ref=v1.0.0"

  # The workspace's `id`, not its `workspace_id` attribute (example 2).
  workspace_id = module.law.id

  # Left at false, deliberately (examples 4 and 5).
  customer_managed_key_enabled = false
}

# The only protection available, since `prevent_destroy` cannot be written here.
resource "azurerm_management_lock" "sentinel" {
  name       = "sentinel-no-offboard"
  scope      = module.law.id
  lock_level = "CanNotDelete"
  notes      = "Deleting the Sentinel onboarding silences every detection in this workspace"
}

# ── Detections: every one wired from the ONBOARDING module (example 3) ────────
module "rule_impossible_travel" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-sentinel-alert-rule-scheduled.git?ref=v1.0.0"

  name                       = "impossible-travel"
  log_analytics_workspace_id = module.sentinel.workspace_id
  display_name               = "Impossible travel sign-in"
  severity                   = "High"
  query                      = file("${path.module}/queries/impossible_travel.kql")

  query_frequency = "PT15M"
  query_period    = "PT1H" # ≥ frequency, or events between runs are never examined

  tactics    = ["InitialAccess", "CredentialAccess"]
  techniques = ["T1078"]

  custom_details = {
    Account  = "UserPrincipalName"
    SourceIP = "IPAddress"
  }

  entity_mapping = [{
    entity_type   = "Account"
    field_mapping = [{ identifier = "FullName", column_name = "UserPrincipalName" }]
  }]

  incident = {
    create_incident_enabled = true # 🔴 false would raise alerts nobody is asked to look at
    grouping = {
      entity_matching_method = "Selected"
      by_entities            = ["Account"]
    }
  }
}

module "rule_mde_alerts" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-sentinel-alert-rule-ms-security-incident.git?ref=v1.0.0"

  name                       = "mde-high-medium"
  log_analytics_workspace_id = module.sentinel.workspace_id
  display_name               = "Defender for Endpoint high and medium alerts"

  # ⚠️ The LEGACY product string. "Microsoft Defender for Endpoint" is rejected.
  product_filter  = "Microsoft Defender Advanced Threat Protection"
  severity_filter = ["High", "Medium"] # everything absent raises no incident
}

module "rule_fusion" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-sentinel-alert-rule-fusion.git?ref=v1.0.0"

  log_analytics_workspace_id = module.sentinel.workspace_id
  alert_rule_template_guid   = "f71aba3d-28fb-450b-b192-4e76a83015c8"
  # No source_signals: the template's own configuration is the widest coverage.
}

module "anomaly_scanning" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-sentinel-alert-rule-anomaly-built-in.git?ref=v1.0.0"

  log_analytics_workspace_id = module.sentinel.workspace_id
  display_name               = "Anomalous scanning activity"

  enabled = true
  mode    = "Production" # ⚠️ "Flighting" would look configured and not contribute
}

output "sentinel_posture" {
  value = {
    workspace = module.sentinel.workspace_id
    cmk       = module.sentinel.customer_managed_key_enabled
    detections = {
      impossible_travel = module.rule_impossible_travel.detection_gaps
      mde_alerts        = module.rule_mde_alerts.coverage_reductions
      fusion            = module.rule_fusion.is_active
      anomaly_scanning  = module.anomaly_scanning.is_actively_detecting
    }
  }
}

🔒 What the composition gets right: the workspace's id in the onboarding module, every rule's workspace wired from the onboarding module's output so ordering is explicit, a CanNotDelete lock over the gate, create_incident_enabled = true stated rather than defaulted, the legacy product string, mode = "Production" rather than flighting, and a posture output built from each rule's own gap-reporting output.

⚠️ What no plan will tell you: whether the KQL is valid against this workspace, whether the tables it queries exist, whether workspace retention covers the query period, whether the anomaly rule's required data connectors are configured, or whether the Fusion template GUID is the right one for this workspace.

💡 The single most useful line in that output is detections — each rule's own summary of the ways it might not be delivering, gathered where somebody will read it.

📥 Inputs

Input Type Default Notes
workspace_id string Required. Force-new. The workspace's ARM Resource ID, shape-validated.
customer_managed_key_enabled bool false Force-new. 🔴 true is permanent for the workspace.
timeouts object(...) null Create, read, delete only — there is no update operation.

There is no tags variable — the provider exposes none on this resource.

Full schemas
variable "workspace_id" {
  type = string
  # Named the same as the attribute you must NOT pass. See example 2.
  validation {
    condition     = can(regex("(?i)/providers/Microsoft\\.OperationalInsights/workspaces/[^/]+$", var.workspace_id))
    error_message = "workspace_id must be a Microsoft.OperationalInsights/workspaces ARM Resource ID with nothing appended — that is the workspace's `id`, not its `workspace_id` attribute. …"
  }
}

variable "customer_managed_key_enabled" {
  type    = bool
  default = false
  # Provider default kept deliberately: defaulting to true would make the empty call take an
  # IRREVERSIBLE action, and would fail without prerequisites this module cannot check.
}

variable "timeouts" {
  type = object({
    create = optional(string)
    read   = optional(string)
    delete = optional(string)
  })
  default = null
  # No `update`: both arguments are force-new, so the schema omits it.
}

🧾 Outputs

Output Description Sensitive
id The onboarding state's Resource ID. no
workspace_id The onboarded workspace's ARM Resource ID — what the siblings consume. no
customer_managed_key_enabled The permanent CMK decision. no
sentinel_is_onboarded Always true. no
workspace_name, workspace_resource_group_name Parsed from workspace_id; known at plan time. composition
onboarding_state_name Always "default" - the service supports no other. authoring
changing_the_key_setting_can_strand_the_workspace Always true - force-new and irreversible. change review
gates_the_entire_sentinel_family Always true - wire siblings from workspace_id. composition
destroying_this_offboards_sentinel Always true - detection stops. destroy review
is_a_singleton_per_workspace Always true - one per workspace, named default. authoring
create_waits_for_onboarding_to_appear Always true - the create polls; the delete does not. operations
onboarding_alone_detects_nothing Always true - rules and connectors are separate. composition
provider_parse_error_names_a_field_that_does_not_exist Always true - it reports log_analytics_workspace_id. troubleshooting
management_moves_to_the_defender_portal Always true - Sentinel's Azure portal experience retires 2027-03-31 and moves to the Microsoft Defender portal; the ARM resources are unaffected no

🔒 No credential is accepted and none is emitted. Nothing in the Sentinel family takes one.

🧠 Architecture Notes

  • The destroy behaviour leads the documentation because it is the family's central fact. Offboarding Sentinel does not delete the rules — it stops the workspace being a Sentinel workspace, so nothing detects anything. A caller cannot add prevent_destroy (lifecycle is not valid inside a module block), which makes a management lock and a CI approval gate the real controls, and makes them worth naming here rather than leaving to inference.

  • workspace_id is validated because the name collides with the wrong value. azurerm_log_analytics_workspace exports both an id and an attribute literally called workspace_id, and this argument wants the former. A shape check on one Resource ID is unremarkable in isolation; here it prevents a plausible configuration from failing at apply with a service error that mentions neither field. All eight sibling modules carry the same check.

  • workspace_id is emitted although it is also an input, and for an unusual reason: consuming it from here rather than from the workspace module is what creates the ordering edge Terraform needs. The provider's own examples chain it that way. This is the one output in the family that exists as much for its graph effect as for its value.

  • The CMK default diverges from this suite's convention, and the reasoning generalises. The usual rule is to default to the protective value and make the caller type the risky one. That rule assumes the protective value is reversible and achievable. Here it is neither: true cannot be undone for the workspace, and it fails without workspace-side and Key Vault prerequisites this module cannot check. So the provider's false is kept, the constraint is documented on the argument itself, and the setting is emitted for review.

  • The force-new-plus-irreversible combination is spelled out as a sequence, because neither half is alarming alone. A force-new field is routine; an irreversible setting is manageable. Together they mean a plan that reads as an ordinary replacement can offboard Sentinel and then be refused the re-onboarding it just proposed — which is worth three sentences rather than a bullet.

  • The absent update timeout is explained rather than left as a schema surprise. Declaring one fails at plan, and the error does not say why the field is missing.

  • Workspace retention is documented even though this module cannot touch it. Sentinel has no storage of its own, so retention is a hard ceiling on every detection in the workspace and on every investigation afterwards. It belongs in the onboarding conversation, not filed away as a workspace detail.

  • sentinel_is_onboarded is a constant true, and its own description says a constant output is a weak substitute for a guard rail. It earns its place by putting the family's central dependency into a state review at all.

🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
Irreversible actions not taken by default — CMK left false, with the reasoning stated set true, knowingly and permanently
Offboarding risk documented in five sections; lock + approval gate recommended destroy anyway, knowingly
Wrong-value pastes the workspace's workspace_id GUID rejected at plan, with the collision named
Dependency ordering workspace_id emitted so siblings create the edge explicitly wire the workspace's id directly, knowingly
Permanent decisions customer_managed_key_enabled emitted for the record
Schema surprises the missing update timeout explained
Detection ceiling workspace retention documented as bounding every rule
Secrets none accepted, none emitted
  • Before the first apply: decide CMK. It cannot be changed afterwards.
  • Before the first apply: check the workspace's retention against what your detections will need.
  • Before writing any rule: wire its workspace from this module's workspace_id.
  • Before a destroy: it offboards Sentinel and silences every detection in the workspace.

🚀 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.
  • 🔴 A destroy offboards Sentinel and silences every detection in the workspace. Use a CanNotDelete lock; prevent_destroy is not available to a module caller.
  • 🔴 Never plan a customer_managed_key_enabled change casually. truefalse reads as a replacement and cannot complete.
  • ⚠️ Every change here is a destroy-and-recreate — there is no update path.
  • ℹ️ Onboarding provisions the Sentinel solution onto the workspace; the provider's default create is 30 minutes.
  • ℹ️ If Sentinel was enabled from the portal, import rather than create (example 10).

🧪 Testing

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

  • workspace_id is a Microsoft.OperationalInsights/workspaces ARM Resource ID with nothing appended — a bare GUID is rejected;
  • the timeouts block accepts no update field;
  • customer_managed_key_enabled is a boolean and defaults to false;
  • no output is sensitive, because nothing sensitive is accepted;
  • 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. A GUID in workspace_id fails; a workspace ARM Resource ID passes and customer_managed_key_enabled reads back as false.

What only plan and apply exercise:

  • whether the workspace exists and the identity may onboard it;
  • whether the Microsoft.SecurityInsights provider is registered.

What no Terraform command checks at any stage:

  • whether the workspace has already been onboarded with CMK — the mismatch that turns a replacement plan into an offboarded workspace;
  • whether the CMK prerequisites are in place on the workspace and the Key Vault;
  • whether the workspace's retention suits the detections that will run against it;
  • what a destroy will silence — the rules, connectors and watchlists pointed at this workspace are not visible from here.

💬 Example Output

Outputs:

customer_managed_key_enabled = false
id                           = "/subscriptions/00000000-.../resourceGroups/rg-sentinel-prod/providers/Microsoft.OperationalInsights/workspaces/law-sentinel-prod/providers/Microsoft.SecurityInsights/onboardingStates/default"
sentinel_is_onboarded        = true
workspace_id                 = "/subscriptions/00000000-.../resourceGroups/rg-sentinel-prod/providers/Microsoft.OperationalInsights/workspaces/law-sentinel-prod"

ℹ️ Note the two IDs: workspace_id stops at the workspace, and id continues into Microsoft.SecurityInsights/onboardingStates/default. The siblings want the shorter one.

🔒 customer_managed_key_enabled = false is the permanent decision, on the record.

💡 sentinel_is_onboarded = true is a constant — read it as "and this is what a destroy would take away".

🔍 Troubleshooting

Symptom Cause Fix
Plan rejects workspace_id The workspace's workspace_id GUID attribute was passed instead of its id. Use .id (example 2).
A rule fails on a first apply saying the workspace is not onboarded The rule's workspace was wired from the workspace module, so nothing ordered onboarding first. Wire it from module.sentinel.workspace_id (example 3).
An update timeout you set is silently ignored, with no error This module's timeouts is a typed object with no update attribute, and Terraform's object-type conversion discards undeclared keys without raising anything. The Unsupported argument: update diagnostic exists, but only inside a resource block's own timeouts -- which a module caller never writes. Do not set update; there is no update operation to time out. Nothing will warn you, so check the key is absent rather than relying on a plan error.
A CMK change plans as a replacement Both arguments are force-new. ⚠️ Do not apply it — see example 4.
The replacement failed and Sentinel is now off The service refused re-onboarding with customer_managed_key_enabled = false. Re-onboard with true; the workspace cannot go back (example 4).
Apply fails when enabling CMK The workspace or Key Vault prerequisites are not in place. Complete both first — they are not checkable here (example 5).
Every detection stopped at once The onboarding was destroyed. Re-onboard; prevent it with a lock (example 1).
Wanted prevent_destroy on this resource lifecycle is not valid inside a module block. Use a CanNotDelete lock (example 1).
Rules deploy but find nothing in older data Workspace retention is shorter than the query period. A workspace setting, invisible here (example 7).
An import does not resolve The path segment is onboardingStates/defaults in the provider's documentation. Check the final segment (example 10).
Apply fails with the provider not registered Microsoft.SecurityInsights is not registered on the subscription. az provider register --namespace Microsoft.SecurityInsights.
Wanted to tag this resource The provider exposes no tags. Tag the workspace.

🔗 Related Docs

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