KSKS Security Research
Home / DevOps & automation

Sentinel as code: greenfield CI/CD design

Sentinel as Code with Terraform· part 2 of 3
SentinelDefender portalTerraformAzure DevOpsGitHub Actions

Chapter 1 explained Terraform. This chapter applies it to a whole Sentinel estate built from nothing: what can be code, what cannot, what to set up on day zero in Azure and in your DevOps platform, and a pipeline design you can defend in front of a security architect.

TL;DR
A greenfield Sentinel in 2026 is a SIEM plus XDR build that lives in the Defender portal from day one. Terraform covers the platform and the content lifecycle. ARM templates cover Content Hub and codeless connectors. A short, written runbook covers the consent steps that automation is deliberately not allowed to take. The pipeline validates on the pull request, applies to dev automatically, applies to prod only after a named person approves, and runs a scheduled plan to catch drift.

Two decisions that come before any code

1. The portal is no longer a choice.

  • Sentinel in the Azure portal retires on 31 March 2027. After that it exists only in the Microsoft Defender portal.
  • Since July 2025, a new customer's first workspace is onboarded to the Defender portal automatically, if the person onboarding is a subscription Owner or User Access Administrator and is not working through Azure Lighthouse delegation. Consultants often are delegated, so plan for the manual step.
  • The Defender XDR connector is enabled automatically on Defender portal onboarding. Do not script it separately.
  • Runbooks, RBAC design and analyst training should target the Defender portal, not the Azure blade.

2. Which tables go to which storage tier.

Analytics tierData lake tier
ForDetection, hunting, workbooks, incidentsLong retention, historical investigation, KQL and notebook jobs
Analytic rules can use itYesNo, it is not real-time
Retention30 days by default (90 free for Sentinel solution tables), up to 2 yearsUp to 12 years
Typical tablesIdentity, endpoint, cloud control plane: anything a rule fires onFirewall, proxy, NetFlow, verbose audit kept for forensics

Analytics-tier data is mirrored to the lake at no extra charge. Sending data to the lake only has its own ingestion and processing meters. Since 23 September 2026, data lake onboarding is part of Sentinel onboarding, and you enable the lake tier per table in Table management. Write the tiering decision down per table, with an owner. It is the biggest lever on the bill, and the one most often decided by accident.

What is Terraform, what is ARM, what stays human

Terraformazurerm providerWorkspace + onboardingScheduled + NRT rulesFusion + ML rulesAutomation rulesWatchlists + itemsTI indicators18 fixed connectorsDCRs, tables, RBACWorkbooks, Logic AppsARM or REST APIno provider resourceContent Hub solutionsCCF connectorsSummary rulesUEBA, anomaliesRepo connectionsHuman, in a portalconsent and trustDefender onboardingM365 / Entra consentThird-party secretsLogic App authLake tier per table
Every item in the human lane is someone granting trust. Automation is deliberately not allowed to grant trust to itself.
Human stepWhoWhy it resists automation
Onboard the workspace to the Defender portalSecurity Administrator or Global AdministratorA tenant-level action, not a resource
Consent for Microsoft 365 and Entra connectorsGlobal AdministratorTenant-wide consent, outside the resource plane
Third-party credentialsApplication ownerThe credential cannot live in the repository
Authorise Logic App API connectionsEngineerEach connection needs an interactive sign-in once, or a managed identity granted a role
Enable the data lake tier per tableSecurity AdministratorPortal-driven in Table management
UEBA data source selectionSecurity AdministratorPer-tenant choice with Entra and Defender for Identity prerequisites

The greenfield build, in dependency order

Phase 0Landing zonefully codePhase 1Platformcode + humanPhase 2IngestionmixedPhase 3Contentfully codePhase 4Operationscode + designall infrastructure as codecode plus a human or ARM step
Each phase depends on the one before it. A rule cannot exist before Sentinel is enabled, and a rule on a table nothing feeds never fires and never errors.
PhaseWhat gets builtHow
0. Landing zoneResource groups, tags, locks, the Log Analytics workspace, RBAC groups, platform diagnostic settingsTerraform
1. PlatformEnable Sentinel, onboard to the Defender portal, table tiering and retention, UEBA and anomaliesTerraform, plus a human for Defender onboarding and an API call or portal step for UEBA
2. IngestionFirst-party connectors, third-party and codeless connectors, data collection rules and transformationsTerraform for fixed connectors and DCRs, ARM for codeless, a human for consent and credentials
3. ContentContent Hub solutions, analytic rules, hunting queries, parsers, watchlists, workbooksTerraform, with Content Hub through ARM
4. OperationsPlaybooks, automation rules, incident settings, SOC runbooks and RACITerraform for resources, people for the operating model

Repository design

One repository, one folder per kind of content, one Terraform root, and a separate state file per environment. The branch and the pipeline stage decide the target environment. The repository does not.

sentinel-as-code/
rules/                 analytic rules, one YAML per detection
automation/            automation rules
watchlists/            CSV per watchlist + small YAML header
hunting/               hunting queries and parsers (KQL files)
workbooks/             workbook JSON
playbooks/             exported Logic App ARM templates
arm/                   Content Hub and codeless connector templates
terraform/
  providers.tf  variables.tf  outputs.tf
  platform.tf          workspace, onboarding, tables, RBAC
  content.tf           reads rules/, automation/, watchlists/, hunting/
  environments/dev.tfvars  environments/prod.tfvars
scripts/validate_rules.py  the quality gate, runs with no Azure access
azure-pipelines.yml   or  .github/workflows/
docs/runbook-human-steps.md   the consent steps, with owners
  • State per environment: same storage account, different blob key: sentinel-dev.tfstate and sentinel-prod.tfstate. One environment's run can never touch the other's state.
  • Platform and content can split later. When the content team and the platform team are different people, give each its own Terraform root and state so a rule change never plans against the workspace.
  • Rule identity is the GUID, not the file name or display name. Never reuse or change one.

Identities and roles

Least privilege means scoping each identity to the smallest thing that works:

IdentityRoleScope
Pipeline identityMicrosoft Sentinel ContributorThe workspace
Pipeline identityStorage Blob Data ContributorThe state storage account
Pipeline identity, if it owns the workspace, tables or DCRsLog Analytics Contributor (or Contributor)The Sentinel resource group
Pipeline identity, if it deploys playbooksLogic App ContributorThe playbook resource group
Pipeline identity, only if Terraform creates role assignmentsRole Based Access Control Administrator, with conditionsAs narrow as possible
Sentinel itself (Azure Security Insights)Microsoft Sentinel Automation ContributorThe playbook resource group, so automation rules can run playbooks
Person running the one-time bootstrapOwnerThe subscription, for the bootstrap only

Day-zero setup, first time ever

The order matters. Azure comes first because the DevOps platform needs something to connect to.

Step 1: Azure foundation (once, by a subscription Owner, in Cloud Shell or the CLI)

  • Create the resource group and the Log Analytics workspace, and enable Sentinel on it.
  • Create the state storage account: Entra-only access, public blob access off, versioning on, and a tfstate container.
  • Onboard the workspace to the Defender portal, if that did not happen automatically.

This is the one part that cannot itself be in the pipeline, because the pipeline needs the state storage and its identity to exist before it can run. Keep it in a reviewed bootstrap script in the repository.

Step 2: the DevOps platform. Pick one.

Azure DevOps is a separate service at dev.azure.com, not a resource you deploy in the Azure portal. The portal's app launcher has a shortcut to it.

Sign in at dev.azure.com with a Microsoft work, school or personal account, or with GitHub. Choose New organization, pick a name and a hosting geography, and link an Azure subscription for billing. The free tier (five Basic users, one Microsoft-hosted parallel job with up to 1,800 minutes a month, one self-hosted job) only applies once billing is linked. For a company, connect the organisation to the Entra tenant under Organization settings → Microsoft Entra.

The pipeline design

Pull requestengineer proposesValidateschema, KQL, testsPlandiff posted to PRMerge to mainreviewer approvesApply to devautomaticSmoke checkfires on test data?Approval gatenamed reviewerApply to prodprod workspaceDrift plannightly schedulehuman decisionpipeline jobcheck against Azure
Three human decisions, and the machines do everything in between. The nightly plan is what catches a rule an analyst tuned by hand during an incident.
StageRuns onNeeds Azure?Purpose
ValidateEvery pull requestNoSchema, KQL hygiene, MITRE names, entity mappings, the validator's own tests, terraform fmt and validate
PlanEvery pull requestRead onlyShows the reviewer exactly what will change in Azure
Apply to devMerge to mainWrite, devLands the change where it can be tested
Smoke checkAfter dev applyRead, devDoes the rule run and fire on replayed test data?
Apply to prodAfter approvalWrite, prodThe only path to production
Drift planNightly scheduleRead onlyFlags anything changed in the portal, without reverting it blindly

The pipeline file

The production stage, in full. The dev stage is the same with the sentinel-dev variable group and environment and no approval.

azure-pipelines.yml (excerpt)
trigger:
  branches: { include: [main] }
  paths: { include: [rules/*, terraform/*] }

variables:
  serviceConnection: sc-sentinel-oidc    # the service connection's name

stages:
  - stage: validate
    jobs:
      - job: validate
        pool: { vmImage: ubuntu-latest }
        steps:
          - script: |
              pip install -r requirements-dev.txt
              python scripts/validate_rules.py --strict
              pytest -q
            displayName: Validate rules (no Azure access)

  # ... plan and deploy_dev stages follow the same shape ...

  - stage: deploy_prod
    dependsOn: deploy_dev
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    variables:
      - group: sentinel-prod
    jobs:
      - deployment: apply
        environment: sentinel-prod       # the Approvals check lives here
        pool: { vmImage: ubuntu-latest }
        strategy:
          runOnce:
            deploy:
              steps:
                - checkout: self         # deployment jobs do not check out by default
                - script: |
                    curl -sSLo tf.zip https://releases.hashicorp.com/terraform/1.9.8/terraform_1.9.8_linux_amd64.zip
                    unzip -o tf.zip -d "$(Agent.ToolsDirectory)/tf"
                    echo "##vso[task.prependpath]$(Agent.ToolsDirectory)/tf"
                  displayName: Install pinned Terraform
                - task: AzureCLI@2
                  displayName: terraform apply
                  inputs:
                    azureSubscription: ${{ variables.serviceConnection }}
                    scriptType: bash
                    scriptLocation: inlineScript
                    workingDirectory: terraform
                    inlineScript: |
                      terraform init -input=false \
                        -backend-config="resource_group_name=$(TFSTATE_RESOURCE_GROUP)" \
                        -backend-config="storage_account_name=$(TFSTATE_STORAGE_ACCOUNT)" \
                        -backend-config="container_name=tfstate" \
                        -backend-config="key=sentinel-prod.tfstate" \
                        -backend-config="use_oidc=true" \
                        -backend-config="use_azuread_auth=true"
                      terraform apply -input=false -auto-approve \
                        -var-file=environments/prod.tfvars
                  env:
                    ARM_USE_OIDC: true
                    ARM_CLIENT_ID: $(AZURE_CLIENT_ID)
                    ARM_TENANT_ID: $(AZURE_TENANT_ID)
                    ARM_SUBSCRIPTION_ID: $(AZURE_SUBSCRIPTION_ID)
                    ARM_ADO_PIPELINE_SERVICE_CONNECTION_ID: $(SERVICE_CONNECTION_ID)
                    SYSTEM_ACCESSTOKEN: $(System.AccessToken)
                    SYSTEM_OIDCREQUESTURI: $(System.OidcRequestUri)
                    TF_VAR_log_analytics_workspace_id: $(LOG_ANALYTICS_WORKSPACE_ID)
How the login works here
The AzureCLI@2 task references the service connection, which makes the pipeline's OIDC token available. The azurerm provider and backend read SYSTEM_ACCESSTOKEN, SYSTEM_OIDCREQUESTURI and the service connection ID, request the token, and exchange it with Entra. No password is involved at any point.
use_azuread_auth is not optional here
Without use_azuread_auth=true, the azurerm backend tries to fetch the storage account key. A least-privilege pipeline identity has only a data-plane role on the state account, so init fails. Granting key access to make it work would undo the point of OIDC.

Three ways to do Sentinel CI/CD

ApproachStrengthsWeaknessesUse when
TerraformDrift detection, dependencies, one language for platform and content, a real plan to reviewState to protect; gaps for Content Hub, settings and codeless connectorsYou own the whole build
Sentinel Repositories (built in)Set up from the portal; no state; deploys ARM and Bicep content; parameter files per workspaceFive connections per workspace; deletions are not propagated; content only; needs Owner on the resource group to connectA content team that wants Git without running Terraform
ARM or Bicep directlyCovers everything the API coversVerbose; no plan; no drift detectionThe gaps: Content Hub, codeless connectors, summary rules

The defensible answer in a design review is a hybrid: Terraform for the platform and content lifecycle, ARM for the gaps (deployed from the same pipeline), and a written runbook for the consent steps. To build all of this yourself, part 3 is a hands-on lab.

Traps that cost a day each

The workflow deploys and its API connection stays unauthorised. It fails at run time. Deploy connections first, authorise them once, then reference them by resource ID.

What to automate next, in order

  • Analytic rules: the highest change rate and the clearest review value. Start here.
  • Automation rules and watchlists: small, stable, and rules depend on them.
  • Hunting queries and parsers: saved searches, same pattern.
  • Workbooks: JSON in the repository, one resource each.
  • Playbooks: exported ARM templates, plus the connection runbook step.
  • Content Hub solutions: ARM from the solution package, deployed by the same pipeline.
  • Tables, retention and DCRs: once the tiering decision is written down.

Test yourself

Sources

Checked 25 October 2026. Re-check retirement dates and onboarding behaviour before quoting them to a client: the Azure portal retirement has already moved once.