Terraform from zero for security engineers
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.
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:
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:
| Command | What it does | Changes Azure? |
|---|---|---|
terraform init | Downloads the providers the code needs and connects to the state storage | No |
terraform plan | Compares code, state and live Azure, then prints the difference: + create, ~ update, - destroy | No |
terraform apply | Makes the changes the plan showed | Yes |
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.
{
"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 template | Bicep | Terraform | |
|---|---|---|---|
| Who makes it | Microsoft | Microsoft | HashiCorp, with Microsoft co-maintaining azurerm |
| Clouds | Azure only | Azure only | Azure, AWS, GCP, GitHub, Cloudflare, Okta and thousands more, in one language |
| New Azure features | Available as soon as Azure ships them | Available as soon as Azure ships them | Arrive when the provider adds them: days to months later |
| State | None. Azure is the record | None. Azure is the record | A state file you store and protect |
| Preview of changes | what-if | what-if | plan, precise, and the industry habit |
| Removing what you deleted from code | Needs deployment stacks or complete mode | Needs deployment stacks or complete mode | Built in |
| Readability | Low | High | High |
| Job market | Microsoft-only shops | Microsoft-only shops | The default IaC skill in job descriptions, especially consulting and multi-cloud |
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).
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,queryandseverityare required. - Create, read, update and delete code that turns your block into REST calls: a
PUTto create or update, aGETto read back, aDELETEto destroy.
Read a resource block like a sentence:
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.idSo 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:
| Term | What it is | Example |
|---|---|---|
| Provider | The plugin that talks to one platform | hashicorp/azurerm |
| Resource | Something Terraform creates and owns | azurerm_sentinel_watchlist |
| Data source | Something Terraform only reads, owned by someone else | data "azurerm_managed_api" |
| Module | A reusable folder of your own Terraform code, called with inputs | A sentinel-rules module used by dev and prod |
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:
- Provider home: registry.terraform.io/providers/hashicorp/azurerm. The left sidebar groups resources by service. Sentinel is under Sentinel, Logic Apps under Logic App, workspaces under Log Analytics.
- One resource: the URL pattern is
.../latest/docs/resources/<type without azurerm_>, for example resources/sentinel_alert_rule_scheduled. - Source and issues: github.com/hashicorp/terraform-provider-azurerm. Search the issues before assuming a feature exists.
- The gaps: the azapi provider reaches any Azure API that azurerm does not cover yet.
Every resource page has the same four sections. Read them in this order:
| Section | What you get from it |
|---|---|
| Example Usage | A working block to start from |
| Arguments Reference | Every field, marked required or optional, with allowed values |
| Attributes Reference | What you can read back after creation, such as .id |
| Import | How to bring an existing, portal-made resource under Terraform |
terraform init
terraform providers schema -json \
| jq -r '.provider_schemas[].resource_schemas | keys[]' \
| grep sentinelThe 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 type | What it manages |
|---|---|
sentinel_log_analytics_workspace_onboarding | Turns Sentinel on for a workspace. Everything else depends on it. |
sentinel_alert_rule_scheduled | The everyday analytic rule: KQL on a schedule, entity mapping, incident grouping. |
sentinel_alert_rule_nrt | Near-real-time rule: runs every minute, for the few detections that cannot wait. |
sentinel_alert_rule_fusion | Enables Microsoft's multistage-attack correlation rule from its template. |
sentinel_alert_rule_machine_learning_behavior_analytics | Enables a built-in ML behaviour analytics rule. |
sentinel_alert_rule_ms_security_incident | Creates incidents from alerts raised by other Microsoft security products. |
sentinel_alert_rule_threat_intelligence | Matches your logs against the threat-intelligence indicators in the workspace. |
sentinel_alert_rule_anomaly_built_in | Configures one of Microsoft's built-in anomaly rules. |
sentinel_alert_rule_anomaly_duplicate | A tuned copy of a built-in anomaly rule, with your own thresholds. |
sentinel_automation_rule | Runs on incident create or update: tag, assign, change severity, close, or call a playbook. |
sentinel_watchlist | A named lookup table that KQL can join against, such as service accounts or VIPs. |
sentinel_watchlist_item | One row in a watchlist. |
sentinel_threat_intelligence_indicator | One indicator of compromise, such as an IP, domain or file hash, as a STIX pattern. |
sentinel_metadata | Source, 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:
A Sentinel build also uses resources that are not named sentinel_:
| Need | Resource type |
|---|---|
| The workspace itself | azurerm_log_analytics_workspace |
| Table plan and retention | azurerm_log_analytics_workspace_table |
| Hunting queries and parsers | azurerm_log_analytics_saved_search |
| Ingestion and transformation | azurerm_monitor_data_collection_rule, ..._endpoint, ..._rule_association |
| Workbooks | azurerm_application_insights_workbook |
| Playbooks | azurerm_logic_app_workflow, azurerm_logic_app_trigger_custom, azurerm_logic_app_action_custom, azurerm_logic_app_standard |
| Playbook connections | azurerm_api_connection |
| Permissions | azurerm_role_assignment |
| Anything else as an ARM template | azurerm_resource_group_template_deployment |
Four resources, written out
Tag every new incident from the lab rules so the SOC can filter them out.
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:
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 filesterraform {
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:
| Value | Secret? | Store it in |
|---|---|---|
| Rule settings, prefixes, retention days | No | environments/*.tfvars, committed and reviewed |
| Subscription ID, tenant ID, client ID of the pipeline identity | No, they are identifiers | Pipeline variables: GitHub repository variables, or an Azure DevOps variable group |
| Workspace resource ID, state storage account name | No | Pipeline variables, per environment |
| Azure login for the pipeline | Would be | Nowhere: OIDC (workload identity federation) means there is no password to store |
| Third-party API keys a connector needs | Yes | Azure Key Vault, read with a data "azurerm_key_vault_secret" block. Never in a .tfvars file |
| The state file | Can contain secrets | A 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:
| Connection | Why | When |
|---|---|---|
| Git repository | The code and tfvars, the single source of truth | Checked out at the start of every job |
| Terraform Registry | Downloads the azurerm provider at the version the lock file names | init |
| Entra ID | Swaps the pipeline's signed OIDC token for a short-lived Azure access token | init, plan and apply |
| State storage | Reads what exists, locks against parallel runs, writes the result | plan reads, apply writes |
| ARM API | Reads live resources and makes the changes | plan reads, apply writes |
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.
| Piece | GitHub option | Azure DevOps option |
|---|---|---|
| Where the code lives | GitHub repository | Azure Repos (or a GitHub repo connected to Azure DevOps) |
| What runs the jobs | GitHub Actions | Azure Pipelines |
| How it logs in to Azure | Entra app with a federated credential (OIDC) | Service connection using workload identity federation (the same OIDC idea) |
| Release gate | Environment with a required reviewer | Environment with an approval check |
| Where IDs are kept | Repository or environment variables | Variable 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.
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]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
}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.tfvarsAfter 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.