KSKS Security Research
Home / DevOps & automation

Terraform from zero for security engineers

Sentinel as Code with Terraform· part 1 of 3
TerraformazurermARM / BicepSentinel

Written for a security engineer who knows Sentinel well and has never written Terraform. By the end you can read a Terraform project, find any Azure resource in the docs, and explain to a design review how one analytic rule gets from a file to a workspace.

TL;DR
Terraform describes infrastructure in files and makes reality match them. The azurerm provider is a plugin that knows how to create, read, update and delete about 1,100 kinds of Azure resource through the Azure Resource Manager API. 32 of those are Sentinel. You look each one up in the Terraform Registry, write a block, and run plan then apply. Terraform owns the delivery. The detection logic is still yours.

What Terraform is

Terraform is infrastructure as code. Instead of clicking through a portal, you write a file that says what should exist. You do not write the steps to create it:

main.tf
resource "azurerm_resource_group" "lab" {
  name     = "rg-sentinel-lab"
  location = "centralindia"
}

That style is called declarative. You state the destination, and Terraform works out the route. If the resource group already exists exactly like that, running it again does nothing. If someone renamed a tag in the portal, Terraform puts it back. Three commands do almost all the work:

CommandWhat it doesChanges Azure?
terraform initDownloads the providers the code needs and connects to the state storageNo
terraform planCompares code, state and live Azure, then prints the difference: + create, ~ update, - destroyNo
terraform applyMakes the changes the plan showedYes

Terraform also keeps a state file: a record that says "this block in my code is this resource ID in Azure". That is how it knows that deleting a block means "destroy that rule", and how it spots a change someone made by hand. In a team, state never lives on a laptop. It sits in an Azure Storage blob that every pipeline run shares and locks.

ARM templates vs Bicep vs Terraform

All three are infrastructure as code, and all three end up calling the same Azure Resource Manager API. The difference is the language and what happens around the call.

Azure's original format: JSON, verbose, and exactly what Sentinel's Export button gives you.

rule.json (excerpt)
{
  "type": "Microsoft.OperationalInsights/workspaces/providers/alertRules",
  "apiVersion": "2023-02-01",
  "name": "[concat(parameters('workspace'), '/Microsoft.SecurityInsights/2d9c6a17-4f3b-41e8-9c5d-8a7b2e6f0d14')]",
  "kind": "Scheduled",
  "properties": {
    "displayName": "Interactive sign-in by a known service account",
    "severity": "Medium",
    "query": "SigninLogs | where ...",
    "queryFrequency": "PT1H",
    "queryPeriod": "PT1H",
    "triggerOperator": "GreaterThan",
    "triggerThreshold": 0,
    "enabled": true
  }
}
ARM templateBicepTerraform
Who makes itMicrosoftMicrosoftHashiCorp, with Microsoft co-maintaining azurerm
CloudsAzure onlyAzure onlyAzure, AWS, GCP, GitHub, Cloudflare, Okta and thousands more, in one language
New Azure featuresAvailable as soon as Azure ships themAvailable as soon as Azure ships themArrive when the provider adds them: days to months later
StateNone. Azure is the recordNone. Azure is the recordA state file you store and protect
Preview of changeswhat-ifwhat-ifplan, precise, and the industry habit
Removing what you deleted from codeNeeds deployment stacks or complete modeNeeds deployment stacks or complete modeBuilt in
ReadabilityLowHighHigh
Job marketMicrosoft-only shopsMicrosoft-only shopsThe default IaC skill in job descriptions, especially consulting and multi-cloud
Why most teams pick Terraform anyway
A security team rarely lives in one cloud. The same engineer may deploy Sentinel, an AWS GuardDuty setting, a GitHub branch rule and a Cloudflare DNS record in one week. Terraform does all of it with one language, one review habit (plan) and one pipeline. The cost is the state file and a short lag behind new Azure features. For those gaps, Terraform can still deploy an ARM template through azurerm_resource_group_template_deployment, or call any Azure API through the separate azapi provider, so the pipeline stays one pipeline.

Providers: the part that clicks

Azure has thousands of REST APIs. Terraform does not know any of them by itself. That knowledge comes from providers: plugins that terraform init downloads. The Azure one is called azurerm (Azure Resource Manager).

Your .tf fileresource blocksazurerm providerschema + CRUD codeARM REST APImanagement.azure.comSecurityInsightsrules, watchlistsMicrosoft.LogicplaybooksOperationalInsightsworkspace, tablesTerraform knows each resource's shape and API. The detection logic is still yours.
A provider turns each resource block into the right REST calls. It knows the shape of a Sentinel rule, not whether the rule is any good.

Inside the provider, each resource type is effectively a pair of things:

  • A schema: which arguments exist, which are required, and which values are allowed. For a scheduled rule, name, display_name, log_analytics_workspace_id, query and severity are required.
  • Create, read, update and delete code that turns your block into REST calls: a PUT to create or update, a GET to read back, a DELETE to destroy.

Read a resource block like a sentence:

anatomy
resource "azurerm_sentinel_alert_rule_scheduled" "detection" { ... }
#  │          │                                   │
#  │          │                                   └─ your local name, any label you like
#  │          └─ resource type: provider prefix + the thing it manages
#  └─ keyword: "I want this to exist"

# Elsewhere you refer to it as:
#   azurerm_sentinel_alert_rule_scheduled.detection.id

So azurerm_sentinel_alert_rule_scheduled is the resource type the provider defines, and detection is a name we chose. Two related terms are easy to confuse:

TermWhat it isExample
ProviderThe plugin that talks to one platformhashicorp/azurerm
ResourceSomething Terraform creates and ownsazurerm_sentinel_watchlist
Data sourceSomething Terraform only reads, owned by someone elsedata "azurerm_managed_api"
ModuleA reusable folder of your own Terraform code, called with inputsA sentinel-rules module used by dev and prod
What the provider does not know
The provider has no idea what a good detection looks like. It will deploy a rule on a table that exists but that nothing feeds, or a rule whose threshold can never be reached: both are valid, and both never fire. Some mistakes it only catches late. A rule that looks back less time than it runs is refused, but only during apply, after someone has approved the deployment. That is why a detection pipeline puts its own validator in front of Terraform. Terraform owns shape and delivery. Your checks own detection quality, and catch mistakes on the pull request.

Where to look things up

You are right that the working method is: pick the provider, then the product, then see which resources exist. This is where:

Every resource page has the same four sections. Read them in this order:

SectionWhat you get from it
Example UsageA working block to start from
Arguments ReferenceEvery field, marked required or optional, with allowed values
Attributes ReferenceWhat you can read back after creation, such as .id
ImportHow to bring an existing, portal-made resource under Terraform
terminal — list every resource type your installed provider knows
terraform init
terraform providers schema -json \
  | jq -r '.provider_schemas[].resource_schemas | keys[]' \
  | grep sentinel

The Sentinel catalogue: 32 resource types

In azurerm 4.x there are 32 resource types whose names start with azurerm_sentinel_: 14 for the platform and its content, and 18 for fixed first-party data connectors. All names below drop the azurerm_ prefix.

Resource typeWhat it manages
sentinel_log_analytics_workspace_onboardingTurns Sentinel on for a workspace. Everything else depends on it.
sentinel_alert_rule_scheduledThe everyday analytic rule: KQL on a schedule, entity mapping, incident grouping.
sentinel_alert_rule_nrtNear-real-time rule: runs every minute, for the few detections that cannot wait.
sentinel_alert_rule_fusionEnables Microsoft's multistage-attack correlation rule from its template.
sentinel_alert_rule_machine_learning_behavior_analyticsEnables a built-in ML behaviour analytics rule.
sentinel_alert_rule_ms_security_incidentCreates incidents from alerts raised by other Microsoft security products.
sentinel_alert_rule_threat_intelligenceMatches your logs against the threat-intelligence indicators in the workspace.
sentinel_alert_rule_anomaly_built_inConfigures one of Microsoft's built-in anomaly rules.
sentinel_alert_rule_anomaly_duplicateA tuned copy of a built-in anomaly rule, with your own thresholds.
sentinel_automation_ruleRuns on incident create or update: tag, assign, change severity, close, or call a playbook.
sentinel_watchlistA named lookup table that KQL can join against, such as service accounts or VIPs.
sentinel_watchlist_itemOne row in a watchlist.
sentinel_threat_intelligence_indicatorOne indicator of compromise, such as an IP, domain or file hash, as a STIX pattern.
sentinel_metadataSource, author and support information attached to a piece of content.

The 18 connector resources are all sentinel_data_connector_<name>, where the name is one of:

azure_active_directorymicrosoft_threat_protectionmicrosoft_defender_advanced_threat_protectionazure_advanced_threat_protectionmicrosoft_cloud_app_securityazure_security_centeroffice_365office_atpoffice_irmoffice_power_bioffice_365_projectdynamics_365iotaws_cloud_trailaws_s3threat_intelligencethreat_intelligence_taxiimicrosoft_threat_intelligence
Connectors are the weak spot
Those 18 are the older, fixed connectors. Most new connectors use Microsoft's Codeless Connector Framework or arrive as Content Hub solutions, and neither has a Terraform resource. Also, the Defender XDR connector is switched on automatically when a workspace is onboarded to the Defender portal, so scripting it separately is wasted work. Chapter 2 covers the split in full.

A Sentinel build also uses resources that are not named sentinel_:

NeedResource type
The workspace itselfazurerm_log_analytics_workspace
Table plan and retentionazurerm_log_analytics_workspace_table
Hunting queries and parsersazurerm_log_analytics_saved_search
Ingestion and transformationazurerm_monitor_data_collection_rule, ..._endpoint, ..._rule_association
Workbooksazurerm_application_insights_workbook
Playbooksazurerm_logic_app_workflow, azurerm_logic_app_trigger_custom, azurerm_logic_app_action_custom, azurerm_logic_app_standard
Playbook connectionsazurerm_api_connection
Permissionsazurerm_role_assignment
Anything else as an ARM templateazurerm_resource_group_template_deployment

Four resources, written out

Tag every new incident from the lab rules so the SOC can filter them out.

automation.tf
resource "azurerm_sentinel_automation_rule" "tag_lab" {
  name                       = "6f1d2c3b-8a9e-4b7c-9d0e-1f2a3b4c5d6e" # a GUID
  log_analytics_workspace_id = var.log_analytics_workspace_id
  display_name               = "Tag incidents from lab rules"
  order                      = 1
  triggers_on                = "Incidents"
  triggers_when              = "Created"

  action_incident {
    order  = 1
    labels = ["lab"]
  }
}

Project structure: what files a Terraform project needs

Terraform reads every .tf file in a folder as one configuration, so file names are a convention for humans. This is the convention most teams use:

repository layout
sentinel-as-code/
├── rules/                       # detection content, one YAML file per rule
│   └── service-account-interactive-signin.yaml
├── terraform/
│   ├── providers.tf             # which providers, which versions, where state lives
│   ├── variables.tf             # the inputs this code accepts, with types and checks
│   ├── rules.tf                 # the resources (could be main.tf, or split per area)
│   ├── outputs.tf               # values printed after apply, e.g. deployed rule ids
│   ├── environments/
│   │   ├── dev.tfvars           # values for dev: non-secret only
│   │   └── prod.tfvars          # values for prod: non-secret only
│   └── .terraform.lock.hcl      # exact provider versions, committed
├── .github/workflows/  or  azure-pipelines.yml
└── .gitignore                   # .terraform/, *.tfstate, *.tfplan, local backend files
providers.tf
terraform {
  required_version = ">= 1.9"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"        # any 4.x, never a surprise 5.0
    }
  }

  # Empty on purpose: the pipeline passes the storage details at init time,
  # so no environment-specific value is hard-coded here.
  backend "azurerm" {}
}

provider "azurerm" {
  features {}

  # A least-privilege pipeline identity cannot register Azure resource
  # providers at subscription scope. Register them once during bootstrap.
  resource_provider_registrations = "none"
}

Variables: what is needed, and where each one lives

Terraform gets a variable's value from, in rising priority: the default in variables.tf, a .tfvars file, an environment variable named TF_VAR_<name>, or a -var flag. The real decision is where each value is stored:

ValueSecret?Store it in
Rule settings, prefixes, retention daysNoenvironments/*.tfvars, committed and reviewed
Subscription ID, tenant ID, client ID of the pipeline identityNo, they are identifiersPipeline variables: GitHub repository variables, or an Azure DevOps variable group
Workspace resource ID, state storage account nameNoPipeline variables, per environment
Azure login for the pipelineWould beNowhere: OIDC (workload identity federation) means there is no password to store
Third-party API keys a connector needsYesAzure Key Vault, read with a data "azurerm_key_vault_secret" block. Never in a .tfvars file
The state fileCan contain secretsA locked-down storage account: Entra-only access, no public blob access, versioning on

What Terraform talks to

Picture Terraform as one block. It does not sit inside Azure or inside Sentinel. It is a program that runs on a pipeline runner and reaches out to five things:

Git repositorycode + tfvarsPipeline runnerGitHub or Azure DevOpsTerraform Registryprovider downloadTerraforminit · plan · applyEntra IDOIDC token, no secretState storagewhat exists nowARM APIcreate · update · deleteSentinel workspacerules go liveTerraformGit and pipelineAzure and identity
Terraform is the block in the middle. Every arrow is a real network call during a pipeline run.
ConnectionWhyWhen
Git repositoryThe code and tfvars, the single source of truthChecked out at the start of every job
Terraform RegistryDownloads the azurerm provider at the version the lock file namesinit
Entra IDSwaps the pipeline's signed OIDC token for a short-lived Azure access tokeninit, plan and apply
State storageReads what exists, locks against parallel runs, writes the resultplan reads, apply writes
ARM APIReads live resources and makes the changesplan reads, apply writes
Terraform never talks to Sentinel directly
There is no agent inside Sentinel. Every change goes through the Azure Resource Manager API, the same front door the portal itself uses. ARM hands it to the Sentinel resource provider, Microsoft.SecurityInsights.

Putting it in a pipeline

Running Terraform from a laptop works for learning, but a team runs it from a pipeline so that every change is reviewed and recorded. You need three pieces: somewhere to keep the code, something to run the jobs, and an identity the jobs log in as.

PieceGitHub optionAzure DevOps option
Where the code livesGitHub repositoryAzure Repos (or a GitHub repo connected to Azure DevOps)
What runs the jobsGitHub ActionsAzure Pipelines
How it logs in to AzureEntra app with a federated credential (OIDC)Service connection using workload identity federation (the same OIDC idea)
Release gateEnvironment with a required reviewerEnvironment with an approval check
Where IDs are keptRepository or environment variablesVariable groups

The Terraform code is identical on both. Only the pipeline file changes. Chapter 2 sets up both from nothing. For the general pipeline and governance concepts, see Detection-as-Code: the pipeline & governance.

One analytic rule, from scratch

The production pattern keeps each rule in a YAML file and has Terraform read every file in the folder. Adding a detection then means adding a file, and nobody edits Terraform.

rules/service-account-interactive-signin.yaml
id: 2d9c6a17-4f3b-41e8-9c5d-8a7b2e6f0d14   # stable GUID: the rule's identity
name: Interactive sign-in by a known service account
severity: Medium
query: |
  SigninLogs
  | where TimeGenerated > ago(1h)
  | where UserPrincipalName in~ (_GetWatchlist("service-accounts") | project Upn)
  | where IsInteractive == true
  | project TimeGenerated, Upn = UserPrincipalName, IPAddress, AppDisplayName
schedule: { frequency: PT1H, period: PT1H }
trigger:  { operator: GreaterThan, threshold: 0 }
mitre:
  tactics:    [DefenseEvasion, Persistence]
  techniques: [T1078]
terraform/rules.tf
locals {
  # Read every rules/*.yaml file into a map keyed by file name.
  rules = {
    for file in fileset("${path.module}/../rules", "*.yaml") :
    trimsuffix(file, ".yaml") => yamldecode(file("${path.module}/../rules/${file}"))
  }
}

resource "azurerm_sentinel_alert_rule_scheduled" "detection" {
  for_each = local.rules               # one rule resource per YAML file

  name                       = each.value.id
  log_analytics_workspace_id = var.log_analytics_workspace_id
  display_name               = "${var.rule_name_prefix}${each.value.name}"
  severity                   = each.value.severity
  query                      = each.value.query
  query_frequency            = each.value.schedule.frequency
  query_period               = each.value.schedule.period
  trigger_operator           = each.value.trigger.operator
  trigger_threshold          = each.value.trigger.threshold
  tactics                    = each.value.mitre.tactics
  techniques                 = each.value.mitre.techniques
}
terminal — first run, logged in with az login
cd terraform
terraform init -backend-config=backend.dev.hcl
terraform fmt -check          # formatting
terraform validate            # arguments match the provider schema
terraform plan -var-file=environments/dev.tfvars

#  + resource "azurerm_sentinel_alert_rule_scheduled" "detection" {
#      + display_name = "[DEV] Interactive sign-in by a known service account"
#      + name         = "2d9c6a17-4f3b-41e8-9c5d-8a7b2e6f0d14"
#      ...
#  Plan: 1 to add, 0 to change, 0 to destroy.

terraform apply -var-file=environments/dev.tfvars

After that, the lifecycle is the reason people adopt Terraform:

  • Change the threshold in the YAML: the next plan says ~ update in-place.
  • Delete the YAML file: the next plan says - destroy.
  • Someone edits the rule in the portal: the next plan shows the drift and puts it back.
  • Change the id: Terraform destroys the old rule and creates a new one, orphaning its incident history. That is why the id is treated as immutable.

Test yourself